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
只使用 `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
```
收到 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>`
区分。收到复测结果后,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`。任务通过
写 `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 审阅决策(不关闭任何终端)
+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
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."""
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):
+72
View File
@@ -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()
+14
View File
@@ -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"