#!/usr/bin/env python3 """Reclaim ACK worker terminals at the end of a coordination round. Reads the authoritative tasks.yaml and decides, per worker receipt handle, whether the terminal may be closed: - close: every task referencing the handle is ``verified`` and no referenced task has an unresolved ``dispatch.environmentIncidents`` entry; - retain: any referenced task is still open/dispatched/fixed_by_dev/retesting/ blocked/failed_retest/leftover, has an open environment incident, or the receipt references an unknown task. Failed-three-times workers stay open by design (leftover/failed_retest/blocked are all retained); - skip: handles not present in workerReceipts (coordinator terminal, user shells) are never touched. 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. 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}]} """ from __future__ import annotations import argparse import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from launch_worker import ( # noqa: E402 LaunchError, load_authoritative_board, resolve_executable, run_json, ) 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]]: """Map each worker receipt handle to its referenced task facts.""" tasks_by_id = {task.get("id"): task for task in board.get("tasks", []) if isinstance(task, dict)} handles: dict[str, dict[str, object]] = {} for receipt in board.get("workerReceipts", []): if not isinstance(receipt, dict): continue binding = receipt.get("binding") created_for = receipt.get("createdFor") if not isinstance(binding, dict) or not isinstance(created_for, dict): continue handle = binding.get("handle") task_id = created_for.get("taskId") if not isinstance(handle, str) or not handle: continue entry = handles.setdefault(handle, {"taskIds": [], "tasks": []}) if isinstance(task_id, str) and task_id not in entry["taskIds"]: entry["taskIds"].append(task_id) entry["tasks"].append(tasks_by_id.get(task_id)) return handles def unresolved_incidents(task: dict | None) -> list[str]: if not isinstance(task, dict): return [] dispatch = task.get("dispatch") if not isinstance(dispatch, dict): return [] incidents = dispatch.get("environmentIncidents") if not isinstance(incidents, list): return [] return [ str(incident.get("id")) for incident in incidents if isinstance(incident, dict) and incident.get("status") == "open" ] def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]: task_ids = entry["taskIds"] tasks = entry["tasks"] statuses: list[str] = [] for task in tasks: if isinstance(task, dict): statuses.append(str(task.get("status"))) else: statuses.append("unknown-task") incidents: list[str] = [] for task in tasks: incidents.extend(unresolved_incidents(task)) if incidents: return ( "retain", f"unresolved environment incident(s): {', '.join(incidents)}", statuses, ) bad = [status for status in statuses if status in KEEP_STATUSES or status == "unknown-task"] if bad: return ( "retain", f"referenced task(s) not verified: {', '.join(bad)} (task ids: {', '.join(task_ids)})", statuses, ) if not task_ids: return "retain", "receipt has no task reference", statuses return "close", "all referenced tasks verified", statuses 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}") close = run_json( [str(orca), "terminal", "close", "--terminal", handle, "--tab", "--json"], "terminal close", ) result = close.get("result") if isinstance(result, dict): closed_handle = result.get("handle") or (result.get("terminal") or {}).get("handle") if closed_handle not in (None, handle): raise LaunchError(f"terminal close 回执 handle 不匹配: {closed_handle!r}") listing = run_json( [str(orca), "terminal", "list", "--json"], "terminal list", ) terminals = listing.get("result", {}).get("terminals") if isinstance(terminals, list) and any( isinstance(item, dict) and item.get("handle") == handle for item in terminals ): 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: project_root, board = load_authoritative_board(args.project_root) except LaunchError as exc: 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): if args.handle and handle != args.handle: continue entry = handles[handle] decision, reason, statuses = decide(handle, entry) closed: bool | None = None if decision == "close" and args.apply: try: close_terminal(resolve_executable("orca"), handle) closed = True except LaunchError as exc: decision = "uncertain" reason = f"close failed: {exc}" closed = None results.append( { "handle": handle, "decision": decision, "reason": reason, "tasks": entry["taskIds"], "statuses": statuses, "closed": closed, } ) print( json.dumps( {"mode": "apply" if args.apply else "dry-run", "handles": results}, ensure_ascii=False, ) ) return 0 if __name__ == "__main__": sys.exit(main())