diff --git a/CHANGELOG.md b/CHANGELOG.md index be273cb2..14cfc7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The drive root can stop a done Orca lane in its own Run.** `orca_ends_session` + blocked every `orca orchestration worker-stop`, so a root that had decided a lane was + done had to message the lane to call `worker_done`. One literal `worker-stop --dispatch ` + now passes when the caller is the main session whose `ORCA_TERMINAL_HANDLE` coordinates the + dispatch's Run. Lanes, subagents, other Runs, loops, `run-stop`, and `worker-release` stay blocked. + - **Primitive hooks keep their fire limits across capt-hook releases.** `nudge()`, `gate()`, and other primitives used the framework's versioned install path as their state identity, resetting `max_fires` on each release. They now key on the diff --git a/captain_hook/builtin_packs/general/hooks/_sessions.py b/captain_hook/builtin_packs/general/hooks/_sessions.py index 2e6bb4c0..147c20a1 100644 --- a/captain_hook/builtin_packs/general/hooks/_sessions.py +++ b/captain_hook/builtin_packs/general/hooks/_sessions.py @@ -344,6 +344,11 @@ def inline_run(coordinator: str) -> dict[str, str]: return {"orca orchestration run-show --id run_inline": json.dumps(shown)} +def inline_dispatch(dispatch: str, run: str = "run_inline") -> dict[str, str]: + shown = {"ok": True, "result": {"dispatch": {"id": dispatch, "runId": run}}} + return {f"orca orchestration worker-show --dispatch {dispatch}": json.dumps(shown)} + + def inline_screen(*tail: str) -> str: return json.dumps({"ok": True, "result": {"terminal": {"source": "screen", "tail": list(tail)}}}) @@ -1205,6 +1210,12 @@ def settled_close(evt: BaseHookEvent, handle: str, tab: bool) -> Proposal | None ) +def own_dispatch(evt: BaseHookEvent, dispatch: str) -> bool: + shown = ("orca", "orchestration", "worker-show", "--dispatch", dispatch, "--json") + run = orca_json(shown, "result", "dispatch", "runId") + return isinstance(run, str) and coordinates(evt, run) + + def still_settled(action: Proposal) -> bool: worker = settled_worker(action.payload["terminal"]) return worker is not None and worker["dispatchId"] == action.payload["dispatch"] diff --git a/captain_hook/builtin_packs/general/hooks/sessions.py b/captain_hook/builtin_packs/general/hooks/sessions.py index 20f0dd15..aa71259b 100644 --- a/captain_hook/builtin_packs/general/hooks/sessions.py +++ b/captain_hook/builtin_packs/general/hooks/sessions.py @@ -46,6 +46,7 @@ hosts_agent, inline_class_rulings, inline_create, + inline_dispatch, inline_ruling, inline_run, inline_screen, @@ -57,6 +58,7 @@ literal_pid, lone_pane, nested, + own_dispatch, owner_lift, pid_verdict, process_class, @@ -167,6 +169,9 @@ } HELP_FLAGS = frozenset({"--help", "-h"}) ORCA_READ = "Read sessions with `orca terminal list|show|read|wait` and leave ending them to the owner." +ORCA_STOP_FIX = ( + "Only the root coordinating its Run stops a done lane: one literal worker-stop, or `orca-gc --done `." +) ORCA_CLOSE_SCOPES = frozenset({"all", "worktree"}) CLOSE_FIX = f"Close only an idle orphan this session created, or ask the owner to {LATER_SESSION}." KEY_ALIASES = { @@ -210,6 +215,7 @@ CLASS_ALLOW = {"allow": True, "relied_on": ["ccn:c9b27c1"]} settling = partial(guarded, env={"ORCA_TERMINAL_HANDLE": "term_root"}) ORCA_GC = ".agents/skills/orca/scripts/orca-gc" +STOPPED = "ctx_a0f447e7e42c" @guard( @@ -774,7 +780,8 @@ def orca_ending(spelling: str, group: str, verb: str, arguments: Arguments) -> s action = f"removes worktree {worktree} and every terminal in it" case _: action = f"{verb} ends the worker or run {orca_bound(arguments, 'dispatch') or 'named by its arguments'}" - return f"BLOCKED: `{spelling}` {action}, which ends the agent session living there. {ORCA_READ}" + fix = ORCA_STOP_FIX if (group, verb) == ("orchestration", "worker-stop") else ORCA_READ + return f"BLOCKED: `{spelling}` {action}, which ends the agent session living there. {fix}" def help_only(call: Call, values: dict[str, tuple[Scalar | None, ...]]) -> bool: @@ -784,31 +791,43 @@ def help_only(call: Call, values: dict[str, tuple[Scalar | None, ...]]) -> bool: return len(path) == len(call.args) - 1 and len(path) <= 2 and (not path or path[0] in ORCA_GROUPS) -def closed_terminal(call: Call, arguments: Arguments) -> str | None: - values = arguments.values +def lone_target(call: Call, arguments: Arguments, command: tuple[str, str], key: str) -> str | None: if ( call.substituted or call.wrappers or not arguments.complete or not arguments.operands_complete - or orca_command(arguments) != ("terminal", "close") - or values.get("rest") - or not ORCA_CLOSE_SCOPES.isdisjoint(values) + or orca_command(arguments) != command + or arguments.values.get("rest") ): return None - words = arguments.words.get("terminal", ()) - handles = values.get("terminal", ()) - if len(words) != 1 or words[0].value is None or words[0].expandable or not isinstance(handles[0], str): + words = arguments.words.get(key, ()) + targets = arguments.values.get(key, ()) + if len(words) != 1 or words[0].value is None or words[0].expandable or not isinstance(targets[0], str): return None - return None if handles[0].startswith("-") else handles[0] + return None if targets[0].startswith("-") else targets[0] + + +def closed_terminal(call: Call, arguments: Arguments) -> str | None: + if not ORCA_CLOSE_SCOPES.isdisjoint(arguments.values): + return None + return lone_target(call, arguments, ("terminal", "close"), "terminal") + + +def stopped_dispatch(call: Call, arguments: Arguments) -> str | None: + return lone_target(call, arguments, ("orchestration", "worker-stop"), "dispatch") def closes_one_terminal(call: Call) -> bool: return call.name == "orca" and closed_terminal(call, ORCA.bind(call)) is not None +def spelled(call: Call, scan: Scan) -> bool: + return all(other is not call for other in scan.respelled) + + def verifiable_close(call: Call, scan: Scan) -> bool: - return all(other is not call for other in scan.respelled) and sum(map(closes_one_terminal, scan.literal_calls)) == 1 + return spelled(call, scan) and sum(map(closes_one_terminal, scan.literal_calls)) == 1 def terminal_close_denied(spelling: str, handle: str, detail: str) -> str: @@ -867,6 +886,13 @@ def orca_ending_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | return None if (handle := closed_terminal(call, arguments)) is not None and verifiable_close(call, scan): return terminal_close_verdict(call, handle, scan, evt) + if ( + (dispatch := stopped_dispatch(call, arguments)) is not None + and spelled(call, scan) + and runs_once(call, scan) + and own_dispatch(evt, dispatch) + ): + return None found = next( ( (group, verb) @@ -1096,7 +1122,53 @@ def orca_vm_run_flag(call: Call) -> str | None: commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, llm=CLASS_ALLOW, ): Block(pattern="where pid 16002"), + settling( + command=f"orca orchestration worker-stop --dispatch {STOPPED} --json", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Allow(), + settling( + command=f"orca orchestration worker-stop --dispatch {STOPPED} 2>&1 | tail -5", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Allow(), + guarded( + command=f"orca orchestration worker-stop --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Block(pattern=f"worker-stop ends the worker or run `{STOPPED}`"), + settling( + command=f"orca orchestration worker-stop --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + env={"ORCA_TERMINAL_HANDLE": "term_lane"}, + ): Block(pattern="Only the root coordinating its Run"), + settling( + command=f"orca orchestration worker-stop --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + agent_id="sibling-lane", + ): Block(pattern="worker-stop ends the worker"), + settling( + command=f"orca orchestration worker-stop --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED, "run_other")}, + ): Block(pattern="worker-stop ends the worker"), + settling(command=f"orca orchestration worker-stop --dispatch {STOPPED}", commands=SETTLED_COMMANDS): Block( + pattern="worker-stop ends the worker" + ), + settling( + command=f"for d in {STOPPED}; do orca orchestration worker-stop --dispatch $d; done", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Block(pattern="worker-stop ends the worker"), + settling( + command=f"true && orca orchestration worker-stop --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Block(pattern="worker-stop ends the worker"), + settling( + command="orca orchestration run-stop --run run_inline", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Block(pattern="run-stop ends the worker"), + settling( + command=f"orca orchestration worker-release --dispatch {STOPPED}", + commands={**SETTLED_COMMANDS, **inline_dispatch(STOPPED)}, + ): Block(pattern="leave ending them to the owner"), guarded(command=f"{ORCA_GC} --run run_7715a23a5657 --dispatch ctx_d83bbb927995"): Allow(), + guarded(command=f"{ORCA_GC} --run run_7715a23a5657 --done ctx_a0f447e7e42c"): Allow(), guarded(command=f"{ORCA_GC} --run run_7715a23a5657 --dispatch ctx_1 --dispatch ctx_2"): Allow(), guarded(command=f"cd /Users/dev/monorepo && {ORCA_GC} --run run_1 --dispatch ctx_1 2>&1 | tail -20"): Allow(), guarded(command=f"{ORCA_GC} --run run_1 --dry-run"): Allow(), diff --git a/docs/guide/packs.qmd b/docs/guide/packs.qmd index f1fc5f82..2480f620 100644 --- a/docs/guide/packs.qmd +++ b/docs/guide/packs.qmd @@ -15,7 +15,8 @@ Packs bundle hooks you install once and get everywhere. Exactly two providers ex started the process, since ancestry alone does not establish ownership, the harness's `TaskStop` tool, since a bare task id does not tell a disposable shell task from a workflow, agent, or teammate session, - `orca terminal close`/`worktree rm`/`worker-stop`, `launchctl` teardown, session-ending + `orca terminal close`/`worktree rm`/`worker-stop` (the root coordinating a dispatch's Orca Run may + stop that dispatch with one literal `worker-stop`), `launchctl` teardown, session-ending `osascript`/`softwareupdate`/`pmset`/`tmux`, and `shutdown`/`reboot`, in Bash and in MCP payloads, doc and prompt nudges, task and plan discipline, model-routing nudges, a code-review gate, a docs-freshness gate, a detour