fix(ack): close Orca terminals after worker completion

This commit is contained in:
2026-09-19 19:49:23 +08:00
parent 147b63aae6
commit 38c4c7f91d
5 changed files with 258 additions and 10 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
## 启动 worker ## 启动 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 或环境异常不关闭。
## 交付配置维护 ## 交付配置维护
+24 -4
View File
@@ -284,7 +284,16 @@ orca orchestration send \
--json --json
``` ```
收到 worker_done 后,Coordinator 写回 `fixed_by_dev`,再按上文派发给 Test 复测。 收到无错误的 Developer `worker_done` 后,Coordinator 写回 `fixed_by_dev`,然后先回收
Developer 对应的 Orca terminal,再按上文派发给 Test 复测:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-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 <n>` 无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`; 区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-task-id> --completed-role test --apply
```
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过 通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写 写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。 `dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
@@ -323,8 +343,8 @@ orca orchestration send \
## 整轮结束时回收 worker ## 整轮结束时回收 worker
只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。统一使用回收脚本, 未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮
不要手工逐个 close: 最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close:
```bash ```bash
# 第一步:dry-run 审阅决策(不关闭任何终端) # 第一步:dry-run 审阅决策(不关闭任何终端)
+143 -1
View File
@@ -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 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 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 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: Output is a single JSON document:
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]} {"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
@@ -43,6 +45,10 @@ from launch_worker import ( # noqa: E402
KEEP_STATUSES = frozenset( KEEP_STATUSES = frozenset(
{"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"} {"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]]: 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: def close_terminal(orca: Path, handle: str) -> None:
"""Show-verify identity, close the tab, then confirm it left the live list.""" """Show-verify identity, close the tab, then confirm it left the live list."""
try:
show = run_json( show = run_json(
[str(orca), "terminal", "show", "--terminal", handle, "--json"], [str(orca), "terminal", "show", "--terminal", handle, "--json"],
"terminal show", "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") terminal = show.get("result", {}).get("terminal")
if not isinstance(terminal, dict) or terminal.get("handle") != handle: if not isinstance(terminal, dict) or terminal.get("handle") != handle:
raise LaunchError(f"terminal show 未返回匹配的 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}") 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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根") parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策") parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
parser.add_argument("--handle", help="只处理指定 handle(默认全部)") 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) args = parser.parse_args(argv)
try: 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)) print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
return 2 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) handles = collect_handle_tasks(board)
results: list[dict[str, object]] = [] results: list[dict[str, object]] = []
for handle in sorted(handles): for handle in sorted(handles):
+72
View File
@@ -208,6 +208,70 @@ class MainTests(unittest.TestCase):
self.assertEqual(exit_code, 2) self.assertEqual(exit_code, 2)
self.assertIn("error", payload) 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): class CloseTerminalTests(unittest.TestCase):
def test_close_verifies_show_close_and_live_list(self) -> None: 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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+14
View File
@@ -92,6 +92,20 @@ class AckSkillContentTests(unittest.TestCase):
self.assertIn("failed_retest", kickoff) self.assertIn("failed_retest", kickoff)
self.assertIn("leftover", 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: def test_environment_failures_are_reported_without_consuming_retest_rounds(self) -> None:
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text( skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
encoding="utf-8" encoding="utf-8"