diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 9aad1cf..bf55954 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -104,7 +104,7 @@ python3 /scripts/validate_agents.py .pouch/ack/agents.yaml ## 启动 worker -只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。 +只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。收到无错误的 `worker_done` / Test 复测结果后,按 [orca-adapter.md](references/orca-adapter.md) 用 `reclaim_workers.py --completed-task-id ... --completed-role ... --apply` 关闭对应 Orca terminal;失败、escalation 或环境异常不关闭。 ## 交付配置维护 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index 946e97d..b7d1f1b 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -284,7 +284,16 @@ orca orchestration send \ --json ``` -收到 worker_done 后,Coordinator 写回 `fixed_by_dev`,再按上文派发给 Test 复测。 +收到无错误的 Developer `worker_done` 后,Coordinator 写回 `fixed_by_dev`,然后先回收 +Developer 对应的 Orca terminal,再按上文派发给 Test 复测: + +```bash +python3 /scripts/reclaim_workers.py \ + --project-root \ + --completed-task-id --completed-role developer --apply +``` + +只关闭该 task 的 Developer receipt 绑定 terminal;escalation、失败或环境异常时不要执行。 --- @@ -314,7 +323,18 @@ orca orchestration send \ ``` 无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round ` -区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`; +区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由 +Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`: + +```bash +python3 /scripts/reclaim_workers.py \ + --project-root \ + --completed-task-id --completed-role test --apply +``` + +只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。 + +收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`; 通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过 写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写 `dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。 @@ -323,8 +343,8 @@ orca orchestration send \ ## 整轮结束时回收 worker -只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。统一使用回收脚本, -不要手工逐个 close: +未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮 +最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close: ```bash # 第一步:dry-run 审阅决策(不关闭任何终端) diff --git a/skills/ack/scripts/reclaim_workers.py b/skills/ack/scripts/reclaim_workers.py index e79a263..41c3275 100755 --- a/skills/ack/scripts/reclaim_workers.py +++ b/skills/ack/scripts/reclaim_workers.py @@ -17,7 +17,9 @@ Default mode is dry-run: print decisions only. Pass ``--apply`` to actually show-verify and close. Closing is conservative: identity must match the receipt, the close receipt must be ok, and the handle must disappear from the live terminal list; anything uncertain stays retained and is reported instead -of being retried. +of being retried. A completed role can be reclaimed before the whole task is +verified with ``--completed-task-id`` and ``--completed-role``; this is only +valid after that role has reported success. Output is a single JSON document: {"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]} @@ -43,6 +45,10 @@ from launch_worker import ( # noqa: E402 KEEP_STATUSES = frozenset( {"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"} ) +COMPLETED_ROLE_STATUSES = { + "developer": frozenset({"fixed_by_dev", "retesting", "verified"}), + "test": frozenset({"retesting", "verified"}), +} def collect_handle_tasks(board: dict) -> dict[str, dict[str, object]]: @@ -115,10 +121,25 @@ def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]: def close_terminal(orca: Path, handle: str) -> None: """Show-verify identity, close the tab, then confirm it left the live list.""" - show = run_json( - [str(orca), "terminal", "show", "--terminal", handle, "--json"], - "terminal show", - ) + try: + show = run_json( + [str(orca), "terminal", "show", "--terminal", handle, "--json"], + "terminal show", + ) + except LaunchError: + # A role may already have been reclaimed before the final verified-task + # sweep. Treat a handle absent from the live list as idempotent success; + # retain other show failures for manual reconciliation. + listing = run_json( + [str(orca), "terminal", "list", "--json"], + "terminal list", + ) + terminals = listing.get("result", {}).get("terminals") + if isinstance(terminals, list) and not any( + isinstance(item, dict) and item.get("handle") == handle for item in terminals + ): + return + raise terminal = show.get("result", {}).get("terminal") if not isinstance(terminal, dict) or terminal.get("handle") != handle: raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}") @@ -144,11 +165,70 @@ def close_terminal(orca: Path, handle: str) -> None: raise LaunchError(f"terminal close 后 handle 仍在 live list: {handle}") +def completed_role_handle(board: dict, task_id: str, role: str) -> str: + """Resolve the receipt-bound terminal for a role that just succeeded.""" + + if role not in COMPLETED_ROLE_STATUSES: + raise LaunchError(f"不支持的完成角色: {role}") + tasks = board.get("tasks") + tasks_by_id = ( + {task.get("id"): task for task in tasks if isinstance(task, dict)} + if isinstance(tasks, list) + else {} + ) + task = tasks_by_id.get(task_id) + if not isinstance(task, dict): + raise LaunchError(f"找不到 ACK task: {task_id}") + status = task.get("status") + if status not in COMPLETED_ROLE_STATUSES[role]: + raise LaunchError( + f"{role} 完成回收要求任务处于 {sorted(COMPLETED_ROLE_STATUSES[role])},当前是 {status!r}" + ) + if unresolved_incidents(task): + raise LaunchError(f"task {task_id} 存在未解决环境事件,不能回收 {role} terminal") + + dispatch = task.get("dispatch") + role_dispatch = dispatch.get(role) if isinstance(dispatch, dict) else None + receipt_id = role_dispatch.get("receiptId") if isinstance(role_dispatch, dict) else None + if not isinstance(receipt_id, str) or not receipt_id: + raise LaunchError(f"task {task_id} 缺少 {role} receiptId") + + receipts = board.get("workerReceipts") + receipt = next( + ( + item + for item in receipts + if isinstance(item, dict) and item.get("id") == receipt_id + ), + None, + ) if isinstance(receipts, list) else None + if not isinstance(receipt, dict): + raise LaunchError(f"找不到 {role} receipt: {receipt_id}") + created_for = receipt.get("createdFor") + binding = receipt.get("binding") + if ( + not isinstance(created_for, dict) + or created_for.get("taskId") != task_id + or created_for.get("role") != role + or not isinstance(binding, dict) + or not isinstance(binding.get("handle"), str) + or not binding.get("handle") + ): + raise LaunchError(f"{role} receipt 与 task/role/handle 不匹配: {receipt_id}") + return binding["handle"] + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根") parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策") parser.add_argument("--handle", help="只处理指定 handle(默认全部)") + parser.add_argument("--completed-task-id", help="刚完成的 ACK task ID;与 --completed-role 一起使用") + parser.add_argument( + "--completed-role", + choices=("developer", "test"), + help="刚成功完成的角色;与 --completed-task-id 一起使用", + ) args = parser.parse_args(argv) try: @@ -157,6 +237,68 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False)) return 2 + if bool(args.completed_task_id) != bool(args.completed_role): + print( + json.dumps( + { + "mode": "apply" if args.apply else "dry-run", + "error": "--completed-task-id 与 --completed-role 必须同时提供", + }, + ensure_ascii=False, + ) + ) + return 2 + if args.completed_task_id: + if args.handle: + print( + json.dumps( + { + "mode": "apply" if args.apply else "dry-run", + "error": "完成角色回收不能同时使用 --handle", + }, + ensure_ascii=False, + ) + ) + return 2 + try: + handle = completed_role_handle(board, args.completed_task_id, args.completed_role) + closed: bool | None = None + decision = "close-completed-role" + reason = f"{args.completed_role} reported success for task {args.completed_task_id}" + if args.apply: + close_terminal(resolve_executable("orca"), handle) + closed = True + print( + json.dumps( + { + "mode": "apply" if args.apply else "dry-run", + "handles": [ + { + "handle": handle, + "decision": decision, + "reason": reason, + "tasks": [args.completed_task_id], + "role": args.completed_role, + "closed": closed, + } + ], + }, + ensure_ascii=False, + ) + ) + return 0 + except LaunchError as exc: + print( + json.dumps( + { + "mode": "apply" if args.apply else "dry-run", + "error": str(exc), + }, + ensure_ascii=False, + ) + ) + return 2 + handles = collect_handle_tasks(board) results: list[dict[str, object]] = [] for handle in sorted(handles): diff --git a/tests/test_ack_reclaim_workers.py b/tests/test_ack_reclaim_workers.py index 250f1a5..4f2fc81 100644 --- a/tests/test_ack_reclaim_workers.py +++ b/tests/test_ack_reclaim_workers.py @@ -208,6 +208,70 @@ class MainTests(unittest.TestCase): self.assertEqual(exit_code, 2) self.assertIn("error", payload) + def test_apply_closes_completed_role_receipt_terminal(self) -> None: + completed_task = task("T-1", "fixed_by_dev") + completed_task["dispatch"]["developer"] = {"receiptId": "WR-1"} + board_data = board( + [ + { + "id": "WR-1", + "binding": {"handle": "term_dev"}, + "createdFor": {"taskId": "T-1", "role": "developer"}, + } + ], + [completed_task], + ) + with ( + mock.patch.object( + reclaim_workers, "load_authoritative_board", return_value=(Path("/repo"), board_data) + ), + mock.patch.object( + reclaim_workers, "resolve_executable", return_value=Path("/trusted/orca") + ), + mock.patch.object(reclaim_workers, "close_terminal") as close, + ): + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + exit_code = reclaim_workers.main( + [ + "--project-root", + "/repo", + "--completed-task-id", + "T-1", + "--completed-role", + "developer", + "--apply", + ] + ) + payload = json.loads(buffer.getvalue()) + self.assertEqual(exit_code, 0) + close.assert_called_once_with(Path("/trusted/orca"), "term_dev") + self.assertEqual(payload["handles"][0]["decision"], "close-completed-role") + self.assertTrue(payload["handles"][0]["closed"]) + + def test_completed_role_rejects_unfinished_task(self) -> None: + completed_task = task("T-1", "dispatched") + completed_task["dispatch"]["developer"] = {"receiptId": "WR-1"} + board_data = board([], [completed_task]) + with mock.patch.object( + reclaim_workers, "load_authoritative_board", return_value=(Path("/repo"), board_data) + ): + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + exit_code = reclaim_workers.main( + [ + "--project-root", + "/repo", + "--completed-task-id", + "T-1", + "--completed-role", + "developer", + ] + ) + payload = json.loads(buffer.getvalue()) + self.assertEqual(exit_code, 2) + self.assertIn("当前是 'dispatched'", payload["error"]) + class CloseTerminalTests(unittest.TestCase): def test_close_verifies_show_close_and_live_list(self) -> None: @@ -233,6 +297,14 @@ class CloseTerminalTests(unittest.TestCase): ): reclaim_workers.close_terminal(Path("/trusted/orca"), "term_1") + def test_close_treats_already_closed_handle_as_success(self) -> None: + responses = [ + reclaim_workers.LaunchError("terminal handle not found"), + {"ok": True, "result": {"terminals": [{"handle": "term_2"}]}}, + ] + with mock.patch.object(reclaim_workers, "run_json", side_effect=responses): + reclaim_workers.close_terminal(Path("/trusted/orca"), "term_1") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index fc610d7..2912953 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -92,6 +92,20 @@ class AckSkillContentTests(unittest.TestCase): self.assertIn("failed_retest", kickoff) self.assertIn("leftover", kickoff) + def test_coordinator_reclaims_role_terminal_after_success(self) -> None: + skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text( + encoding="utf-8" + ) + adapter = ( + REPO_ROOT / "skills" / "ack" / "references" / "orca-adapter.md" + ).read_text(encoding="utf-8") + + self.assertIn("--completed-task-id", skill) + self.assertIn("--completed-role", skill) + self.assertIn("--completed-role developer", adapter) + self.assertIn("--completed-role test", adapter) + self.assertIn("orca terminal close --tab", adapter) + def test_environment_failures_are_reported_without_consuming_retest_rounds(self) -> None: skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text( encoding="utf-8"