From 6c5cd05cce185283b1ca7d773e1d7437820f4a97 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 9 Aug 2026 15:24:38 +0900 Subject: [PATCH 1/3] sync: agent-ops from agentic-framework v1.1.193 --- agent-ops/.version | 2 +- .../orchestrate-agent-task-loop/SKILL.md | 8 +- .../assets/default-execution-catalog.json | 23 +- .../scripts/dispatch.py | 102 +++++++- .../scripts/execution_target_policy.py | 23 ++ .../tests/test_dispatch.py | 218 +++++++++++++++++- .../tests/test_execution_target_policy.py | 41 +++- .../tests/test_select_execution_target.py | 5 + 8 files changed, 401 insertions(+), 21 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index df870776..e1aabbc7 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.192 +1.1.193 diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md index 3dc49ce5..1220454a 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md @@ -48,10 +48,10 @@ Each target has: - `execution_class`: `local_model` or `cloud_model`; - optional `selfcheck_required` boolean; - `runtime.command`: a non-empty argv template executed without a shell; -- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, and `auxiliary_logs`; +- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, `terminal_success`, and `auxiliary_logs`; - optional `runtime.output_format`: `text` or `jsonl`. -Command templates may use only `{agent}`, `{model}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, and `{prompt}`. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. +Command templates may use only `{agent}`, `{model}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, `{resume_session_dir}`, and `{prompt}`. `native_session_monitor=true` requires both `resume_command` and `session_path`. `terminal_success=agent_end` requires JSONL output and accepts only a non-retrying final `agent_end` whose last assistant message has `stopReason=stop`; `error`, `aborted`, a missing event, or another stop reason fails closed. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match. @@ -103,7 +103,7 @@ Accept self-check completion only when `## Implementation Checklist` or its supp - Store each attempt under the dispatcher state directory with `locator.json`, `stream.log`, `normalized-output.log`, and `heartbeat.log`. - Record the target id, opaque agent/model identity, execution class, runtime contract, catalog evidence, process identity, workspace identity, timestamps, result, and exact failure evidence. -- Treat stderr as terminal diagnostic evidence. For JSONL, recognize generic terminal event fields such as error/fatal type or severity, rejected/failed status with an error code, and explicit error flags. +- Treat stderr as terminal diagnostic evidence. For JSONL, recognize generic terminal event fields such as error/fatal type or severity, rejected/failed status with an error code, explicit error flags, and a non-retrying `agent_end` whose last assistant message ends with `error` or `aborted`. - Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress. - Never start a duplicate attempt while owned live evidence remains. - Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success. @@ -112,7 +112,7 @@ Accept self-check completion only when `## Implementation Checklist` or its supp ## Work log - Keep one dispatcher-owned `WORK_LOG.md` per task group. -- Append chronological `START` and `FINISH` rows with KST (`Asia/Seoul`) time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator. +- Append chronological `START` and `FINISH` rows with KST (`Asia/Seoul`) time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator. Use the PLAN artifact for worker and self-check rows; use the CODE_REVIEW artifact only for official review rows. - Archive the group log as the next `work_log_N.log` only after every observed task in the group is verified complete and idle. - Work-log write or archive failure is a retryable control-plane failure and prevents exit `0`. diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json index e29eb4cb..3dff48df 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json @@ -25,7 +25,28 @@ "{attempt_dir}/pi-sessions", "{prompt}" ], - "output_format": "jsonl" + "resume_command": [ + "pi", + "-p", + "--mode", + "json", + "--approve", + "--provider", + "iop", + "--model", + "{model}", + "--thinking", + "high", + "--session", + "{resume_session}", + "--session-dir", + "{resume_session_dir}", + "{prompt}" + ], + "output_format": "jsonl", + "session_path": "{attempt_dir}/pi-sessions/*{session_id}*.jsonl", + "native_session_monitor": true, + "terminal_success": "agent_end" } }, "agy-gemini-low": { diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py index 2ae81fb2..b8babc81 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py @@ -390,7 +390,7 @@ def milestone_work_log_path(task: Task) -> Path: def work_log_task_name(task: Task, role: str) -> str: """Return the role-specific active artifact shown in the task column.""" - artifact = task.plan if role == "worker" else task.review + artifact = task.review if role == "review" else task.plan if artifact is None: return task.name return f"{task.name}/{artifact.name}" @@ -2410,6 +2410,40 @@ def failure_report_lines(failure: str, locator: Path) -> list[str]: return lines +def json_agent_terminal_outcome(value: object) -> str | None: + """Return the final agent outcome encoded by a generic JSONL event.""" + if not isinstance(value, dict): + return None + if str(value.get("type", "")).lower() != "agent_end": + return None + if value.get("willRetry") is True: + return None + messages = value.get("messages") + if not isinstance(messages, list): + return None + for message in reversed(messages): + if ( + not isinstance(message, dict) + or str(message.get("role", "")).lower() != "assistant" + ): + continue + stop_reason = message.get("stopReason") or message.get("stop_reason") + normalized = str(stop_reason or "").lower() + if normalized in {"error", "aborted"}: + return "failed" + if normalized == "stop": + return "succeeded" + return None + return None + + +def json_agent_terminal_outcome_from_line(line: str) -> str | None: + try: + return json_agent_terminal_outcome(json.loads(line)) + except json.JSONDecodeError: + return None + + def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None: if channel == "stderr": return line @@ -2424,7 +2458,8 @@ def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None: status = str(value.get("status") or "").lower() subtype = str(value.get("subtype") or "").lower() if ( - event_type in {"error", "fatal", "request.failed", "turn.failed", "rate_limit_event"} + json_agent_terminal_outcome(value) == "failed" + or event_type in {"error", "fatal", "request.failed", "turn.failed", "rate_limit_event"} or severity in {"error", "fatal"} or subtype.startswith("error") or bool(value.get("is_error")) @@ -2586,6 +2621,7 @@ def native_session_path( "model": spec.model, "prompt": "", "resume_session": "", + "resume_session_dir": "", "session_id": session_id, "target_id": str(spec.target_id or ""), "workspace": str(workspace), @@ -3025,6 +3061,11 @@ def build_command( "model": spec.model, "prompt": prompt, "resume_session": str(native_resume_session or ""), + "resume_session_dir": ( + str(native_resume_session.parent) + if native_resume_session is not None + else "" + ), "session_id": session_id, "target_id": str(spec.target_id or ""), "workspace": str(workspace), @@ -3054,6 +3095,7 @@ def preflight_execution_catalog( "model": target.model, "prompt": "", "resume_session": "", + "resume_session_dir": "", "session_id": "preflight-session", "target_id": target_id, "workspace": str(checked_workspace), @@ -3199,6 +3241,11 @@ async def invoke( "model": spec.model, "prompt": "", "resume_session": str(native_resume_session or ""), + "resume_session_dir": ( + str(native_resume_session.parent) + if native_resume_session is not None + else "" + ), "session_id": session_id, "target_id": str(spec.target_id or ""), "workspace": str(workspace), @@ -3299,6 +3346,8 @@ async def invoke( diagnostics: list[str] = [] diagnostic_origins: list[str] = [] control_violation: str | None = None + terminal_success_contract = spec.runtime.get("terminal_success") + terminal_success_seen = False try: runtime_values = { "agent": spec.cli, @@ -3306,6 +3355,11 @@ async def invoke( "model": spec.model, "prompt": prompt, "resume_session": str(native_resume_session or ""), + "resume_session_dir": ( + str(native_resume_session.parent) + if native_resume_session is not None + else "" + ), "session_id": session_id, "target_id": str(spec.target_id or ""), "workspace": str(workspace), @@ -3537,6 +3591,11 @@ async def invoke( line = raw.decode("utf-8", errors="replace").rstrip("\n") stream_log.write(f"[{channel}] {line}\n") stream_log.flush() + if ( + channel == "stdout" + and json_agent_terminal_outcome_from_line(line) == "succeeded" + ): + terminal_success_seen = True diagnostic = terminal_diagnostic(spec.cli, channel, line) if diagnostic: diagnostics.append(diagnostic) @@ -3649,23 +3708,46 @@ async def invoke( classified_failure, classified_evidence = classify_failure_with_evidence( "\n".join(diagnostics[-50:]) ) + stdout_diagnostic_index = next( + ( + index + for index in range(len(diagnostic_origins) - 1, -1, -1) + if diagnostic_origins[index].endswith(":stdout") + ), + None, + ) if return_code != 0 or classified_evidence is not None: failure_class = classified_failure failure_evidence = classified_evidence + elif stdout_diagnostic_index is not None: + failure_class = "generic-error" + failure_evidence = diagnostics[stdout_diagnostic_index] + elif terminal_success_contract == "agent_end" and not terminal_success_seen: + failure_class = "generic-error" + failure_source = "dispatcher-terminal-contract" + failure_evidence = ( + "required agent_end terminal success event was not observed" + ) + failure_evidence_source = "dispatcher:terminal-contract" else: failure_class = None - if failure_class is not None and failure_evidence is not None: + if ( + failure_class is not None + and failure_evidence is not None + and failure_evidence_source is None + ): for index in range(len(diagnostics) - 1, -1, -1): if diagnostics[index] == failure_evidence: failure_evidence_source = diagnostic_origins[index] break - if failure_class in PROVIDER_TRANSPORT_FAILURES: - failure_source = "provider-terminal-diagnostic" - provider_transport_failure_confirmed = failure_evidence is not None - elif failure_evidence is not None: - failure_source = "cli-terminal-diagnostic" - elif return_code != 0: - failure_source = "cli-exit" + if failure_source is None: + if failure_class in PROVIDER_TRANSPORT_FAILURES: + failure_source = "provider-terminal-diagnostic" + provider_transport_failure_confirmed = failure_evidence is not None + elif failure_evidence is not None: + failure_source = "cli-terminal-diagnostic" + elif return_code != 0: + failure_source = "cli-exit" try: append_milestone_event( task, diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py index 8c80a82a..c089cddb 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py @@ -28,6 +28,7 @@ ALLOWED_TEMPLATE_FIELDS = { "model", "prompt", "resume_session", + "resume_session_dir", "session_id", "target_id", "workspace", @@ -106,6 +107,7 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: "output_format", "session_path", "native_session_monitor", + "terminal_success", "auxiliary_logs", } if unknown: @@ -121,6 +123,17 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: raise CatalogError( f"{label}.output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}" ) + terminal_success = value.get("terminal_success") + if terminal_success is not None: + if terminal_success != "agent_end": + raise CatalogError( + f"{label}.terminal_success must be 'agent_end'" + ) + if runtime["output_format"] != "jsonl": + raise CatalogError( + f"{label}.terminal_success requires output_format='jsonl'" + ) + runtime["terminal_success"] = terminal_success for field in ("resume_command", "preflight_command"): if field in value: template = list( @@ -150,6 +163,16 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: if not isinstance(monitor, bool): raise CatalogError(f"{label}.native_session_monitor must be a boolean") runtime["native_session_monitor"] = monitor + if monitor: + missing = [ + field + for field in ("resume_command", "session_path") + if field not in runtime + ] + if missing: + raise CatalogError( + f"{label}.native_session_monitor requires {missing}" + ) auxiliary_logs = value.get("auxiliary_logs", []) if not isinstance(auxiliary_logs, list) or not all( isinstance(item, str) and item for item in auxiliary_logs diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py index 4a4af105..fe5f141b 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -30,7 +30,14 @@ def catalog_value(command: str = "/bin/true") -> dict: "selfcheck_required": True, "runtime": { "command": [command, "--workspace", "{workspace}", "--model", "{model}", "{prompt}"], - "resume_command": [command, "--resume", "{resume_session}", "{prompt}"], + "resume_command": [ + command, + "--resume", + "{resume_session}", + "--session-dir", + "{resume_session_dir}", + "{prompt}", + ], "environment": {"TARGET_ID": "{target_id}"}, "output_format": "jsonl", "native_session_monitor": True, @@ -85,7 +92,7 @@ def task_from_plan(root: Path, plan: Path) -> dispatch.Task: user_review=None, recovery=False, index=1, - write_set={"src/item.txt"}, + write_set={str((root / "src/item.txt").resolve())}, write_set_known=True, plan_hash=dispatch.sha256_file(plan), ) @@ -144,7 +151,13 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): target_id="opaque-id", runtime={ "command": ["runner", "{workspace}", "{model}", "{session_id}", "{attempt_dir}", "{prompt}"], - "resume_command": ["runner", "resume", "{resume_session}", "{prompt}"], + "resume_command": [ + "runner", + "resume", + "{resume_session}", + "{resume_session_dir}", + "{prompt}", + ], }, ) command = dispatch.build_command( @@ -163,7 +176,16 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): native_resume_session=Path("/attempt/session.jsonl"), ) self.assertEqual(command, ["runner", "/workspace", "opaque-model", "session-1", "/attempt", "do work"]) - self.assertEqual(resumed, ["runner", "resume", "/attempt/session.jsonl", "continue"]) + self.assertEqual( + resumed, + [ + "runner", + "resume", + "/attempt/session.jsonl", + "/attempt", + "continue", + ], + ) def test_preflight_checks_executable_and_optional_probe(self): with TemporaryDirectory() as tmp: @@ -258,6 +280,169 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): ) ) + def test_agent_end_terminal_diagnostic_respects_retry_and_stop_reason(self): + retrying = { + "type": "agent_end", + "willRetry": True, + "messages": [ + { + "role": "assistant", + "stopReason": "error", + "errorMessage": "provider_tunnel_error", + } + ], + } + failed = { + **retrying, + "willRetry": False, + } + succeeded = { + "type": "agent_end", + "willRetry": False, + "messages": [{"role": "assistant", "stopReason": "stop"}], + } + + self.assertIsNone( + dispatch.terminal_diagnostic( + "opaque-agent", "stdout", json.dumps(retrying) + ) + ) + self.assertIn( + "provider_tunnel_error", + dispatch.terminal_diagnostic( + "opaque-agent", "stdout", json.dumps(failed) + ) + or "", + ) + self.assertEqual(dispatch.json_agent_terminal_outcome(succeeded), "succeeded") + self.assertIsNone( + dispatch.terminal_diagnostic( + "opaque-agent", "stdout", json.dumps(succeeded) + ) + ) + + def _invoke_fake_json_event( + self, + event: dict, + *, + stderr_line: str | None = None, + ) -> tuple[int, str | None, dict, str]: + with TemporaryDirectory() as tmp: + root = Path(tmp) + plan = write_plan(root) + task = task_from_plan(root, plan) + runner = root / "fake_json_runner.py" + runner.write_text( + "import json\n" + "import sys\n" + f"print(json.dumps({event!r}))\n" + f"print({stderr_line!r}, file=sys.stderr) if {stderr_line!r} else None\n", + encoding="utf-8", + ) + runtime = { + "command": [sys.executable, str(runner)], + "output_format": "jsonl", + "terminal_success": "agent_end", + } + agent = dispatch.AgentSpec( + "fake-json-runner", + "fake-model", + "fake-json-runner/fake-model", + target_id="fake-json-target", + runtime=runtime, + ) + with mock.patch.dict( + os.environ, + {"XDG_STATE_HOME": str(root / "state")}, + ): + store = dispatch.StateStore(root) + try: + return_code, failure, locator = asyncio.run( + dispatch.invoke( + root, + store, + task, + "worker", + agent, + "fake prompt", + ) + ) + record = json.loads(locator.read_text(encoding="utf-8")) + self.assertEqual(record["cli"], "fake-json-runner") + work_log = ( + root / "agent-task" / "group" / "WORK_LOG.md" + ).read_text(encoding="utf-8") + finally: + store.close() + return return_code, failure, record, work_log + + def test_zero_exit_agent_error_is_not_recorded_as_success(self): + event = { + "type": "agent_end", + "willRetry": False, + "messages": [ + { + "role": "assistant", + "stopReason": "error", + "errorMessage": "provider_tunnel_error: recovery_failed", + } + ], + } + + return_code, failure, record, work_log = self._invoke_fake_json_event(event) + + self.assertEqual(return_code, 0) + self.assertEqual(failure, "provider-connection") + self.assertEqual(record["status"], "failed") + self.assertNotIn("succeeded:0", work_log) + + def test_stdout_terminal_error_survives_later_stderr_diagnostic(self): + event = { + "type": "agent_end", + "willRetry": False, + "messages": [ + { + "role": "assistant", + "stopReason": "error", + "errorMessage": "opaque terminal failure", + } + ], + } + + return_code, failure, record, work_log = self._invoke_fake_json_event( + event, + stderr_line="cleanup warning", + ) + + self.assertEqual(return_code, 0) + self.assertEqual(failure, "generic-error") + self.assertEqual(record["failure_evidence_source"], "fake-json-runner:stdout") + self.assertNotIn("succeeded:0", work_log) + + def test_required_agent_end_success_event_fails_closed_when_missing(self): + event = {"type": "message", "text": "partial output only"} + + return_code, failure, record, work_log = self._invoke_fake_json_event(event) + + self.assertEqual(return_code, 0) + self.assertEqual(failure, "generic-error") + self.assertEqual(record["failure_source"], "dispatcher-terminal-contract") + self.assertNotIn("succeeded:0", work_log) + + def test_required_agent_end_success_event_accepts_final_stop(self): + event = { + "type": "agent_end", + "willRetry": False, + "messages": [{"role": "assistant", "stopReason": "stop"}], + } + + return_code, failure, record, work_log = self._invoke_fake_json_event(event) + + self.assertEqual(return_code, 0) + self.assertIsNone(failure) + self.assertEqual(record["status"], "succeeded") + self.assertIn("succeeded:0", work_log) + def test_catalog_source_is_in_runtime_audit_evidence(self): with TemporaryDirectory() as tmp: root = Path(tmp) @@ -271,6 +456,31 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): class GenericDispatcherContractTests(unittest.TestCase): + def test_selfcheck_work_log_uses_worker_plan_artifact(self): + with TemporaryDirectory() as tmp: + directory = Path(tmp) / "agent-task" / "group" / "01_task" + directory.mkdir(parents=True) + plan = directory / "PLAN-local-G05.md" + review = directory / "CODE_REVIEW-cloud-G05.md" + plan.touch() + review.touch() + task = dispatch.Task( + name="group/01_task", + directory=directory, + plan=plan, + review=review, + user_review=None, + recovery=False, + ) + + worker = dispatch.work_log_task_name(task, "worker") + selfcheck = dispatch.work_log_task_name(task, "selfcheck") + official_review = dispatch.work_log_task_name(task, "review") + + self.assertTrue(worker.endswith("/PLAN-local-G05.md")) + self.assertEqual(selfcheck, worker) + self.assertTrue(official_review.endswith("/CODE_REVIEW-cloud-G05.md")) + def test_parallel_limit_contract(self): self.assertEqual(dispatch.validated_max_parallel(0), 0) self.assertEqual(dispatch.validated_max_parallel(3), 3) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py index c227b996..498bf00f 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py @@ -24,9 +24,17 @@ def catalog_value(*, windows: bool = False) -> dict: "selfcheck_required": True, "runtime": { "command": ["runner-a", "--model", "{model}", "{prompt}"], - "resume_command": ["runner-a", "--resume", "{resume_session}", "{prompt}"], + "resume_command": [ + "runner-a", + "--resume", + "{resume_session}", + "--session-dir", + "{resume_session_dir}", + "{prompt}", + ], "output_format": "jsonl", "native_session_monitor": True, + "session_path": "sessions/*{session_id}*.jsonl", }, }, "target-b": { @@ -148,6 +156,37 @@ class ExecutionTargetPolicyTests(unittest.TestCase): with self.assertRaisesRegex(policy.CatalogError, "executable must be a literal"): policy.load_catalog(write_catalog(Path(tmp), value)) + def test_native_session_monitor_requires_resume_command_and_session_path(self): + for missing_field in ("resume_command", "session_path"): + value = catalog_value() + del value["targets"]["target-a"]["runtime"][missing_field] + with ( + self.subTest(missing_field=missing_field), + TemporaryDirectory() as tmp, + self.assertRaisesRegex(policy.CatalogError, missing_field), + ): + policy.load_catalog(write_catalog(Path(tmp), value)) + + def test_terminal_success_contract_requires_jsonl_agent_end(self): + valid = catalog_value() + valid["targets"]["target-a"]["runtime"]["terminal_success"] = "agent_end" + invalid_name = catalog_value() + invalid_name["targets"]["target-a"]["runtime"]["terminal_success"] = "message_end" + invalid_format = catalog_value() + invalid_format["targets"]["target-a"]["runtime"]["terminal_success"] = "agent_end" + invalid_format["targets"]["target-a"]["runtime"]["output_format"] = "text" + with TemporaryDirectory() as tmp: + root = Path(tmp) + loaded = policy.load_catalog(write_catalog(root, valid)) + self.assertEqual( + loaded.targets["target-a"].runtime["terminal_success"], + "agent_end", + ) + with self.assertRaisesRegex(policy.CatalogError, "must be 'agent_end'"): + policy.load_catalog(write_catalog(root, invalid_name)) + with self.assertRaisesRegex(policy.CatalogError, "requires output_format='jsonl'"): + policy.load_catalog(write_catalog(root, invalid_format)) + def test_catalog_revision_changes_with_content(self): with TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py index 580ef39e..0c5e4896 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py @@ -164,6 +164,11 @@ class SelectorTests(unittest.TestCase): self.assertEqual((pi.agent, pi.model), ("pi", "ornith:35b")) self.assertTrue(pi.selfcheck_required) self.assertIn("--thinking", pi.runtime["command"]) + self.assertTrue(pi.runtime["native_session_monitor"]) + self.assertEqual(pi.runtime["terminal_success"], "agent_end") + self.assertIn("--session", pi.runtime["resume_command"]) + self.assertIn("{resume_session_dir}", pi.runtime["resume_command"]) + self.assertIn("{session_id}", pi.runtime["session_path"]) agy = catalog.targets["agy-gemini-high"] self.assertEqual(agy.runtime["auxiliary_logs"], ["{attempt_dir}/agy-cli.log"]) opencode = catalog.targets["opencode-glm-max"] From 1c9ffdd6a81cc4686e249e0d019c49da94ed1f40 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 9 Aug 2026 18:05:43 +0900 Subject: [PATCH 2/3] sync: agent-ops from agentic-framework v1.1.194 --- agent-ops/.version | 2 +- .../prepare-milestone-workspace/SKILL.md | 15 +-- .../scripts/prepare_workspace.py | 93 +++++++++++++++++-- .../tests/test_prepare_workspace.py | 39 ++++++-- agent-ops/skills/common/router.md | 2 +- 5 files changed, 126 insertions(+), 25 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index e1aabbc7..9bc6f2b4 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.193 +1.1.194 diff --git a/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md index 8ab437b2..6ec424d0 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md +++ b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md @@ -1,6 +1,6 @@ --- name: prepare-milestone-workspace -description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../sample-feature-worktree 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다. +description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 사용자가 지정한 현재 workspace에서 대상 feature branch를 생성·재사용해 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../sample-feature-worktree 위치에 X 작업 준비해", "현재 워크스페이스에서 현 마일스톤 시작해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다. --- # Prepare Milestone Workspace @@ -9,7 +9,7 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature ## 목적 -계획 가능한 Milestone 하나를 검증된 `feature/` workspace로 전환하거나 이미 준비된 동일 branch를 재사용하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다. +계획 가능한 Milestone 하나를 검증된 `feature/` workspace로 전환한다. 현재 workspace 실행 모드에서 사용자가 지정한 workspace가 Git Flow develop branch이면 그 자리에서 대상 feature branch를 생성·전환하고, 이미 대상 feature branch이면 재사용한다. 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다. ## 입력 @@ -21,12 +21,12 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature - `target-epics`: `remaining`, `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택) - `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택) -생성 모드의 첫 번째 위치 표현(``)은 workspace로, 두 번째 표현(``)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. 현재 workspace 실행 모드의 `현 마일스톤`은 `current.md`와 현재 feature branch가 함께 가리키는 Milestone으로, 이름을 지정하면 같은 workspace의 branch/current와 정확히 일치해야 한다. `두 번째 Epic`은 `2..2`, `두 번째 Epic까지`와 `1,2번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`, `남은 Epic`은 문서 순서의 미완료 Epic 전체를 선택하는 `remaining`으로 변환한다. +생성 모드의 첫 번째 위치 표현(``)은 workspace로, 두 번째 표현(``)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. 현재 workspace 실행 모드의 `현 마일스톤`은 그 workspace의 `current.md`가 가리키는 단일 Milestone으로 확정하고, 이름을 지정하면 같은 `current.md`와 정확히 일치해야 한다. branch 이름으로 target을 선택하지 않는다. `두 번째 Epic`은 `2..2`, `두 번째 Epic까지`와 `1,2번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`, `남은 Epic`은 문서 순서의 미완료 Epic 전체를 선택하는 `remaining`으로 변환한다. ## 사전 조건 - 생성 모드는 clean Git Flow develop checkout과 정확히 `[계획]`인 Milestone을 요구한다. -- 현재 workspace 실행 모드는 target slug와 일치하는 `feature/` branch, 일치하는 local `current.md`, clean/upstream-synced workspace를 요구하고 Milestone `[계획]` 또는 `[진행중]`을 허용한다. 기록된 active batch 재개만 상태 소유 변경을 허용한다. +- 현재 workspace 실행 모드는 일치하는 local `current.md`와 clean/upstream-synced workspace를 요구하고 Milestone `[계획]` 또는 `[진행중]`을 허용한다. 현재 branch가 target slug와 일치하는 `feature/`이면 재사용하고, Git Flow develop branch이면 같은 workspace에서 target feature branch를 생성·전환한다. 기록된 active batch 재개만 상태 소유 변경을 허용한다. - 두 모드 모두 `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다. - `sync-milestone-workstate mode=consistency-check`가 `ready`여야 한다. - remote와 `gitflow.branch.develop`, `gitflow.prefix.feature`를 확인할 수 있어야 한다. @@ -72,7 +72,8 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work --review-target "$REVIEW_TARGET" ``` - - 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다. + - 현재 workspace가 Git Flow develop branch이면 별도 worktree를 만들지 않고 그 workspace에서 target feature branch를 생성·전환한다. + - 현재 workspace가 target feature branch도 develop branch도 아니거나 `current.md` target이 다르면 다른 worktree를 탐색하지 않고 `FAILED`로 멈춘다. - `remaining`에 미완료 Epic이 없으면 agent/dispatcher를 시작하지 않고 완료 event로 종료한다. - 두 모드 모두 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다. @@ -101,7 +102,7 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work ## 실행 결과 검증 -- [ ] branch가 `gitflow.prefix.feature + milestone file slug`인가 +- [ ] 생성 모드와 현재 workspace 실행 모드 모두 최종 branch가 `gitflow.prefix.feature + milestone file slug`인가 - [ ] branch가 remote에 존재하고 upstream이 연결됐는가 - [ ] 요청 workspace가 정확히 해당 branch의 clean worktree인가 - [ ] workspace-local `agent-roadmap/current.md`가 대상 Milestone만 가리키는가 @@ -128,7 +129,7 @@ Milestone workspace preparation - `[계획]`이 아니거나 잠긴 Milestone의 branch를 만들지 않는다. - consistency check의 `refresh-required`를 `ready`로 간주하지 않는다. - 사용자 소유 변경이 있는 develop checkout이나 기존 workspace를 덮어쓰지 않는다. -- 현재 workspace 실행 모드에서 target이 다른 branch/current를 자동 전환하지 않는다. +- 현재 workspace 실행 모드에서 Git Flow develop branch는 target feature branch로 전환한다. 그 외 target이 다른 feature/topic branch나 다른 `current.md`를 자동 전환하지 않는다. - 선택 Epic 중 하나라도 attention/terminal failure 상태면 dispatcher를 시작하지 않는다. - 복수 Epic batch에서 개별 `EPIC_WORK_ITEMS_READY`만 보고 dispatcher를 먼저 시작하지 않는다. - `git push --force`, destructive rollback, branch/worktree 자동 삭제를 하지 않는다. diff --git a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py index ff38a375..9c64f304 100755 --- a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py @@ -1013,7 +1013,7 @@ def parser() -> argparse.ArgumentParser: value.add_argument( "--existing-workspace", action="store_true", - help="start selected Epic work in the current prepared feature workspace", + help="prepare or reuse the target feature branch in the current workspace and start selected Epic work", ) value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV)) value.add_argument( @@ -1085,11 +1085,14 @@ def prepare_existing(args: argparse.Namespace) -> int: feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature") branch = git(workspace, "branch", "--show-current") expected_branch = f"{feature_prefix}{milestone_slug}" if feature_prefix else "" - if not develop or not feature_prefix or branch != expected_branch: + if not develop or not feature_prefix: + raise PreparationError("gitflow.branch.develop and gitflow.prefix.feature are required") + if branch not in {develop, expected_branch}: raise PreparationError( - f"current workspace must use the target Milestone feature branch: " - f"expected={expected_branch or 'missing-gitflow-config'} actual={branch or 'detached'}" + "current workspace must use the Git Flow develop branch or target Milestone " + f"feature branch: expected={develop}|{expected_branch} actual={branch or 'detached'}" ) + start_from_develop = branch == develop current_path = workspace / "agent-roadmap" / "current.md" expected_current_target = f"phase/{phase_slug}/milestones/{milestone_slug}.md" if ( @@ -1121,8 +1124,10 @@ def prepare_existing(args: argparse.Namespace) -> int: ) if batch_state and batch_state.get("status") != "completed" and not batch_matches: raise PreparationError("another active Epic batch owns the current Milestone workspace") + if start_from_develop and resuming_batch: + raise PreparationError("an active Epic batch must resume on its target feature branch") if not resuming_batch: - ensure_clean(workspace, "feature workspace") + ensure_clean(workspace, "current workspace") upstream = git( workspace, "rev-parse", @@ -1132,7 +1137,7 @@ def prepare_existing(args: argparse.Namespace) -> int: ) if not upstream.endswith(f"/{branch}"): raise PreparationError( - f"feature branch upstream mismatch: branch={branch} upstream={upstream}" + f"workspace branch upstream mismatch: branch={branch} upstream={upstream}" ) if not resuming_batch and not args.dry_run: remote_name = upstream.split("/", 1)[0] @@ -1145,10 +1150,54 @@ def prepare_existing(args: argparse.Namespace) -> int: cwd=workspace, check=False, ).returncode != 0: - raise PreparationError("current feature branch is not synchronized with its upstream") + raise PreparationError("current workspace branch is not synchronized with its upstream") + if start_from_develop and not args.dry_run: + git(workspace, "fetch", args.remote) + + local_ref = f"refs/heads/{expected_branch}" + remote_ref = f"refs/remotes/{args.remote}/{expected_branch}" + local_exists = ref_exists(workspace, local_ref) + remote_exists = ref_exists(workspace, remote_ref) + if start_from_develop: + if upstream != f"{args.remote}/{develop}": + raise PreparationError( + f"develop branch must track {args.remote}/{develop}: actual={upstream}" + ) + remote_develop = f"refs/remotes/{args.remote}/{develop}" + if not ref_exists(workspace, remote_develop): + raise PreparationError(f"remote develop ref missing: {args.remote}/{develop}") + branch_worktrees = [ + item for item in worktrees(workspace) if item.get("branch") == local_ref + ] + if branch_worktrees: + raise PreparationError( + "target feature branch already belongs to another worktree: " + f"{branch_worktrees[0]['worktree']}" + ) + if local_exists and run( + ["git", "merge-base", "--is-ancestor", remote_develop, local_ref], + cwd=workspace, + check=False, + ).returncode != 0: + raise PreparationError( + f"feature branch does not contain current {args.remote}/{develop}" + ) + if not local_exists and remote_exists and run( + ["git", "merge-base", "--is-ancestor", remote_develop, remote_ref], + cwd=workspace, + check=False, + ).returncode != 0: + raise PreparationError( + f"remote feature branch does not contain current {args.remote}/{develop}" + ) + if remote_exists and local_exists and git( + workspace, "rev-parse", local_ref + ) != git(workspace, "rev-parse", remote_ref): + raise PreparationError("existing local and remote feature branches differ") emit( "PREFLIGHT_READY", - branch=branch, + branch=expected_branch, + source_branch=branch, milestone=str(milestone_path), workspace=str(workspace), existing_workspace=True, @@ -1162,7 +1211,33 @@ def prepare_existing(args: argparse.Namespace) -> int: args.planner_target, args.review_target, ) - ensure_clean(workspace, "feature workspace after agent probe") + ensure_clean(workspace, "current workspace after agent probe") + if start_from_develop: + if not local_exists: + if remote_exists: + git( + workspace, + "branch", + "--track", + expected_branch, + f"{args.remote}/{expected_branch}", + ) + else: + git(workspace, "branch", expected_branch, f"{args.remote}/{develop}") + git(workspace, "switch", expected_branch) + if not remote_exists: + git(workspace, "push", "--set-upstream", args.remote, expected_branch) + else: + git( + workspace, + "branch", + "--set-upstream-to", + f"{args.remote}/{expected_branch}", + expected_branch, + ) + branch = expected_branch + emit("FEATURE_BRANCH_PUSHED", branch=branch, remote=args.remote) + ensure_clean(workspace, "feature workspace") state = { "status": "workspace-ready", "milestone": str(milestone_path), diff --git a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py index ff6655a4..c0131775 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py @@ -319,12 +319,18 @@ class PrepareWorkspaceTest(unittest.TestCase): self.assertEqual(coordinate.call_args.kwargs["args"].epics, "2..2") self.assertFalse(command(workspace, "git", "status", "--porcelain=v1")) - def test_existing_workspace_mode_refuses_non_target_branch(self) -> None: + def test_existing_workspace_mode_creates_feature_branch_from_develop(self) -> None: with tempfile.TemporaryDirectory() as raw: - workspace = Path(raw) / "workspace" - command(workspace.parent, "git", "init", "-b", "dev", str(workspace)) + root = Path(raw) + remote = root / "remote.git" + workspace = root / "workspace" + command(root, "git", "init", "--bare", str(remote)) + command(root, "git", "init", "-b", "dev", str(workspace)) + command(workspace, "git", "config", "user.name", "Test Agent") + command(workspace, "git", "config", "user.email", "agent@example.test") command(workspace, "git", "config", "gitflow.branch.develop", "dev") command(workspace, "git", "config", "gitflow.prefix.feature", "feature/") + command(workspace, "git", "remote", "add", "origin", str(remote)) milestone = ( workspace / "agent-roadmap" @@ -341,11 +347,21 @@ class PrepareWorkspaceTest(unittest.TestCase): "## 기능\n\n### Epic: [first] First\n\n- [ ] [first-task] first\n", encoding="utf-8", ) + (workspace / ".gitignore").write_text( + "agent-roadmap/current.md\n", encoding="utf-8" + ) + command(workspace, "git", "add", ".gitignore", "agent-roadmap") + command(workspace, "git", "commit", "-m", "init") + command(workspace, "git", "push", "-u", "origin", "dev") (workspace / "agent-roadmap" / "current.md").write_text( "phase/phase-one/milestones/sample.md\n", encoding="utf-8" ) output = io.StringIO() - with contextlib.redirect_stdout(output): + with ( + contextlib.redirect_stdout(output), + mock.patch.dict(os.environ, {"AGENT_OPS_TESTING": "1"}), + mock.patch.object(MODULE, "coordinate_batch", return_value=0) as coordinate, + ): result = MODULE.main( [ "--existing-workspace", @@ -355,12 +371,21 @@ class PrepareWorkspaceTest(unittest.TestCase): str(milestone.relative_to(workspace)), "--epics", "1..1", - "--dry-run", "--skip-agent-probe", ] ) - self.assertEqual(result, 2) - self.assertIn("current workspace must use", output.getvalue()) + self.assertEqual(result, 0, output.getvalue()) + self.assertIn('"event": "FEATURE_BRANCH_PUSHED"', output.getvalue()) + self.assertEqual( + command(workspace, "git", "branch", "--show-current"), + "feature/sample", + ) + self.assertEqual( + command(workspace, "git", "rev-parse", "feature/sample"), + command(workspace, "git", "rev-parse", "origin/feature/sample"), + ) + self.assertEqual(coordinate.call_args.kwargs["workspace"], workspace) + self.assertFalse(command(workspace, "git", "status", "--porcelain=v1")) def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(self) -> None: with tempfile.TemporaryDirectory() as raw: diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md index 923ef537..90e858fa 100644 --- a/agent-ops/skills/common/router.md +++ b/agent-ops/skills/common/router.md @@ -62,7 +62,7 @@ 라우팅 우선순위: - `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace` 생성 모드를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. 이 형식에서 workspace 위치가 없으면 확인을 요청한다. -- `현 마일스톤에 N번째 에픽 작업 시작해`, `Y 마일스톤에 1,2번째 에픽까지 작업 시작해` 또는 `현|Y 마일스톤에 남은 에픽 작업들 시작해`는 같은 스킬의 현재 workspace 실행 모드를 선택한다. `현 마일스톤`은 current/feature branch의 단일 일치 target, 이름 있는 Milestone은 현재 workspace branch/current와 정확히 일치하는 target만 허용한다. `N번째`는 `N..N`, `N번째까지`는 `1..N`, `1,2번째까지`는 `1..2`, `남은 에픽`은 문서 순서의 미완료 Epic 전체를 뜻하는 `remaining`으로 해석한다. 현재 workspace가 준비되지 않았거나 target과 다르면 workspace를 추정·전환하지 않고 거부한다. +- `현 마일스톤에 N번째 에픽 작업 시작해`, `Y 마일스톤에 1,2번째 에픽까지 작업 시작해` 또는 `현|Y 마일스톤에 남은 에픽 작업들 시작해`는 같은 스킬의 현재 workspace 실행 모드를 선택한다. `현 마일스톤`은 현재 workspace의 `current.md` 단일 target, 이름 있는 Milestone은 같은 workspace의 `current.md`와 정확히 일치하는 target만 허용한다. 현재 branch가 대상 `feature/`이면 그대로 재사용하고, Git Flow develop branch이면 사용자가 지정한 그 workspace에서 대상 feature branch를 생성·전환한 뒤 실행한다. `N번째`는 `N..N`, `N번째까지`는 `1..N`, `1,2번째까지`는 `1..2`, `남은 에픽`은 문서 순서의 미완료 Epic 전체를 뜻하는 `remaining`으로 해석한다. 다른 feature/topic branch이거나 target이 다르면 workspace를 추정·전환하지 않고 거부한다. - 두 모드 모두 선택 Epic을 각각 `prepare-epic-work-items`로 준비하되 전체 `MILESTONE_WORK_ITEMS_READY` 전에는 dispatcher를 시작하지 않는다. - 한 Epic 안에서 작은 작업 직접 처리와 큰 작업 plan 작성을 함께 요청하면 `prepare-epic-work-items`를 선택한다. 이미 존재하는 plan만 세분화하는 요청과 새로운 plan만 작성하는 요청에는 이 스킬을 선택하지 않는다. - 이미 생성된 미착수 pair의 분할만 요청하면 lane과 관계없이 `refine-plans`를 선택한다. 새 plan 작성이나 구현 범위 재분석이 포함되면 `plan`을 선택한다. From 1f2f7f1066fcf165a9e469bae77203b569b6f772 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 10 Aug 2026 00:09:38 +0900 Subject: [PATCH 3/3] sync: agent-ops from agentic-framework v1.1.195 --- agent-ops/.version | 2 +- .../scripts/run_epic_cycle.py | 2 +- .../tests/test_run_epic_cycle.py | 81 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 9bc6f2b4..745550c2 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.194 +1.1.195 diff --git a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py index 293caf65..c01b0ff7 100755 --- a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py @@ -621,7 +621,7 @@ def cycle(args: argparse.Namespace) -> int: emit( "EPIC_BATCH_VALIDATED", identity=identity, - event="EPIC_COMPLETED" if not epic.incomplete_ids else "EPIC_WORK_ITEMS_READY", + terminal="EPIC_COMPLETED" if not epic.incomplete_ids else "EPIC_WORK_ITEMS_READY", plans=len(pairs), ) return 0 diff --git a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py index 5362b45f..cd1261b5 100644 --- a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py @@ -1,6 +1,8 @@ from __future__ import annotations import importlib.util +import io +import json import os from pathlib import Path import subprocess @@ -141,6 +143,85 @@ class EpicCycleContractTest(unittest.TestCase): self.assertEqual(selected.task_ids, ("done-task", "open-task")) self.assertEqual(selected.incomplete_ids, ("open-task",)) + def test_validate_only_emits_batch_event_with_terminal_state(self) -> None: + cases = ( + ("- [x] [task-one] completed", set(), [], "EPIC_COMPLETED"), + ( + "- [ ] [task-one] pending", + {"task-one"}, + [(Path("PLAN-local-G01.md"), Path("CODE_REVIEW-local-G01.md"))], + "EPIC_WORK_ITEMS_READY", + ), + ) + with tempfile.TemporaryDirectory() as raw: + workspace = Path(raw) + milestone = ( + workspace + / "agent-roadmap" + / "phase" + / "phase-one" + / "milestones" + / "sample-milestone.md" + ) + milestone.parent.mkdir(parents=True) + current = workspace / "agent-roadmap" / "current.md" + current.parent.mkdir(parents=True, exist_ok=True) + current.write_text( + "phase/phase-one/milestones/sample-milestone.md\n", + encoding="utf-8", + ) + + for task_line, task_union, pairs, terminal in cases: + with self.subTest(terminal=terminal): + milestone.write_text( + "# Milestone: [sample-01] Sample\n\n" + "## 상태\n\n[계획]\n\n" + "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n" + "## 기능\n\n" + "### Epic: [sample-epic] Sample Epic\n\n" + f"{task_line}\n", + encoding="utf-8", + ) + args = MODULE.parser().parse_args( + [ + "--workspace", + str(workspace), + "--milestone", + str(milestone.relative_to(workspace)), + "--epic", + "sample-epic", + "--execution-catalog", + "/runtime/catalog.json", + "--planner-target", + "planner-primary", + "--validate-only", + ] + ) + output = io.StringIO() + with ( + mock.patch.object(MODULE, "resolve_workspace", return_value=workspace), + mock.patch.object( + MODULE, + "git", + side_effect=lambda _workspace, *arguments, **_kwargs: { + ("config", "--get", "gitflow.prefix.feature"): "feature/", + ("branch", "--show-current"): "feature/sample-milestone", + }[arguments], + ), + mock.patch.object( + MODULE, + "validate_pairs", + return_value=(pairs, task_union), + ), + mock.patch("sys.stdout", output), + ): + self.assertEqual(MODULE.cycle(args), 0) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload["event"], "EPIC_BATCH_VALIDATED") + self.assertEqual(payload["terminal"], terminal) + self.assertEqual(payload["plans"], len(pairs)) + def test_validate_pair_rejects_task_outside_epic(self) -> None: with tempfile.TemporaryDirectory() as raw: workspace = Path(raw)