#!/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. 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"} ) 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.""" show = run_json( [str(orca), "terminal", "show", "--terminal", handle, "--json"], "terminal show", ) 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 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(默认全部)") 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 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())