From 783feb588f82218c30f23bf7e5021b637a7c86c1 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 22:28:12 -0700 Subject: [PATCH 1/5] =?UTF-8?q?grants:=20=F0=9F=90=9B=20Bound=20evidence?= =?UTF-8?q?=20reads=20to=20fixed=20windows=20and=20name=20the=20snapshot?= =?UTF-8?q?=20host's=20refusals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limit root excerpts to a trailing 16 MiB window starting at a whole line. Read older owner words from live recorded evidence; exclude expired records. Report snapshot host errors without changing admission limits. Update evidence docs and correct the documented ask expiry to 7 days. Cover excerpt boundaries, recorded words, expiry, and host errors in tests. --- captain_hook/context.py | 2 +- captain_hook/grants/evidence.py | 14 ++++---------- captain_hook/primitives/llm.py | 2 +- captain_hook/snapshots/client.py | 2 ++ captain_hook/transcripts.py | 24 +++++++++++++++++++----- docs/guide/grants.qmd | 15 +++++++++------ tests/test_context.py | 15 ++++++++++++++- tests/test_grants.py | 26 ++++++++++++++++++++++++++ tests/test_snapshot_owner.py | 32 +++++++++++++++++++++++++++++++- 9 files changed, 107 insertions(+), 25 deletions(-) diff --git a/captain_hook/context.py b/captain_hook/context.py index d700181c..52aa2901 100644 --- a/captain_hook/context.py +++ b/captain_hook/context.py @@ -284,7 +284,7 @@ def root_transcript_block( def root_excerpt(self, needles: Sequence[str], *, around: int = 2) -> Session | None: """The root session's events that mention any of ``needles``, with ``around`` events either side, - from the whole transcript rather than its tail; ``None`` outside a lane. + from the last 16 MiB of its transcript rather than its event tail; ``None`` outside a lane. """ from captain_hook.transcripts import root_excerpt diff --git a/captain_hook/grants/evidence.py b/captain_hook/grants/evidence.py index 56283535..1a5625ea 100644 --- a/captain_hook/grants/evidence.py +++ b/captain_hook/grants/evidence.py @@ -165,13 +165,13 @@ def ask_evidence(use: Any) -> list[Evidence]: return answer_evidence(use.ref.tool_use_id or f"{use.ts:%s}", *result, use.result_ts or use.ts) -def recorded_asks(evt: BaseHookEvent) -> list[Evidence]: +def recorded(evt: BaseHookEvent, kind: str) -> list[Evidence]: from captain_hook.grants import store at = store.now() return [ item - for grant in store.grants("ask", tree_of(evt)) + for grant in store.grants(kind, tree_of(evt)) if grant.revoked is None and (grant.expires is None or grant.expires > at) for item in grant.evidence ] @@ -187,7 +187,7 @@ class Asked: def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: needles = self.needles(evt) if self.needles else () read = {item.id: item for use in owner_uses(evt, needles, self.window) for item in ask_evidence(use)} - merged = {item.id: item for item in recorded_asks(evt)} | read + merged = {item.id: item for item in recorded(evt, "ask")} | read return sorted(merged.values(), key=lambda item: item.said_at.timestamp() if item.said_at else 0.0) @@ -201,12 +201,6 @@ def words_evidence(text: str, at: datetime | None) -> Evidence: return Evidence(id=key, source="words", quote=text, said_at=at, key=key) -def recorded_words(evt: BaseHookEvent) -> list[Evidence]: - from captain_hook.grants import store - - return [item for grant in store.grants("words", tree_of(evt)) for item in grant.evidence] - - def queued_words(turn: Any) -> list[tuple[str, Any]]: return [ (event.detail.prompt or "", event) @@ -232,7 +226,7 @@ class OwnerWords: window: int = OWNER_WINDOW def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: - items = {item.id: item for item in recorded_words(evt) if not machine_written(item.quote, self.machine)} + items = {item.id: item for item in recorded(evt, "words") if not machine_written(item.quote, self.machine)} needles = self.needles(evt) if self.needles else () for session, prompts_are_owner in owner_sessions(evt, needles): for turn in session.recent_messages(self.window).turns: diff --git a/captain_hook/primitives/llm.py b/captain_hook/primitives/llm.py index e97fd311..a54de951 100644 --- a/captain_hook/primitives/llm.py +++ b/captain_hook/primitives/llm.py @@ -106,7 +106,7 @@ def llm_evaluate[M: BaseModel]( the event fires inside a subagent or teammate lane, adds that window of the root session that spawned the lane as ````, so a judge can read the user's words a lane never saw. ``root_excerpt`` maps the event to needles (a quote, a thread, the text being judged) and adds every - root event that mentions one as ````, however far back the root transcript it sits. + event in the last 16 MiB of the root transcript that mentions one as ````. """ from cc_transcript.render import clip diff --git a/captain_hook/snapshots/client.py b/captain_hook/snapshots/client.py index 0cd493f0..b4f72ff4 100644 --- a/captain_hook/snapshots/client.py +++ b/captain_hook/snapshots/client.py @@ -187,6 +187,8 @@ def __call__(self, request: dict[str, object]) -> dict[str, Any]: process.stdin.write(encode_frame(frame)) process.stdin.flush() response = read_frame(process.stdout) + if response.get("op") == "error" and response.get("id") == self._id: + raise SnapshotProtocolError(f"snapshot host failed the request: {response.get('error')}") if ( type(response.get("protocol")) is not int or type(response.get("id")) is not int diff --git a/captain_hook/transcripts.py b/captain_hook/transcripts.py index e4be3a8a..5116f313 100644 --- a/captain_hook/transcripts.py +++ b/captain_hook/transcripts.py @@ -2,6 +2,7 @@ import hashlib import json +import os import re import threading from collections import deque @@ -30,6 +31,7 @@ # must not smuggle path separators or traversal past that trust boundary. INVALID_SESSION_ID = re.compile(r"[/\\]|\x00|^\.\.?$") ROOT_TAIL_EVENTS = 256 +ROOT_EXCERPT_BYTES = 16 * 1024 * 1024 def user_classifier(events: Sequence[TranscriptEvent], *, path: Path | None = None) -> UserClassifier: @@ -326,11 +328,18 @@ def root_transcript(path: str | Path, events: int) -> LazyTranscript: ) -def root_excerpt(path: str | Path, needles: Sequence[str], *, around: int = 2, limit: int = 20) -> Session: - """The earliest and the newest ``limit`` events of a lane's root session transcript whose line contains - any of ``needles``, each with ``around`` events either side, streamed from the whole file so an answer - far older than the tail still reaches the judge. The earliest are kept because a quote is first said by - whoever it came from and echoed by agents after, so the newest alone drop the words and keep the echoes. +def root_excerpt( + path: str | Path, + needles: Sequence[str], + *, + around: int = 2, + limit: int = 20, + tail_bytes: int = ROOT_EXCERPT_BYTES, +) -> Session: + """The earliest and the newest ``limit`` events in the last ``tail_bytes`` of a lane's root session + transcript whose line contains any of ``needles``, each with ``around`` events either side. Owner words + older than that tail reach a grant judge through the store's recorded ``words`` and ``ask`` records. + The earliest are kept because a quote is first said by whoever it came from and echoed by agents after. A needle matches as typed or JSON-escaped, with or without its non-ASCII escaped. """ from cc_transcript.parser import parse_events_from_bytes @@ -352,6 +361,11 @@ def root_excerpt(path: str | Path, needles: Sequence[str], *, around: int = 2, l after = 0 reqenv.checkpoint() with Path(path).open("rb") as transcript: + if (start := transcript.seek(0, os.SEEK_END) - tail_bytes) > 0: + transcript.seek(start - 1) + transcript.readline() + else: + transcript.seek(0) for line in transcript: if any(form in line for form in forms): window = [*before, line] diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index 4ba115d9..67b38806 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -199,7 +199,7 @@ The defaults are `model="small"`, `specialty="review"`, and `deadline=20` second `contexts` accepts the same prompt contexts as other LLM hooks. `transcript` and `root_transcript` default to `False`; set either to a message count, `"recent"`, or `"full"` when the judge needs more context. -`root_excerpt` maps an event to search needles in the root transcript. +`root_excerpt` maps an event to search needles in the last 16 MiB of the root transcript. `tool_results=False` keeps tool output out of those windows by default. The evidence providers determine which words can authorize an action: @@ -220,11 +220,14 @@ judge raises `ValueError`: those sources need a judge to interpret the owner's w selection, typed answer, and per-question notes. It distinguishes what was shown from what was chosen. The builtin `general` pack records answers on `PostToolUse(AskUserQuestion)` as tree-bound `ask` grants with one use and a -24-hour expiry. These are evidence records; the declaration mints its own kind -after the judge allows. +7-day expiry, and a root session's own prompts on `UserPromptSubmit` as `words` +grants with the same expiry. These are evidence records; the declaration mints its +own kind after the judge allows. `Asked` and `OwnerWords` default to a window of 60 and accept a `needles` callback -to include root-session excerpts farther back. Set +to include matching events from the last 16 MiB of the root transcript. Evidence +reads never scan a whole transcript: owner words older than these windows reach the +judge only through the recorded `ask` and `words` records. Set `needles=lambda evt: ("C_EXAMPLE",)` to search for that channel id. `OwnerWords(machine=("",))` excludes prompts containing that marker. Tool output, teammate messages, and notifications are not owner evidence. @@ -257,8 +260,8 @@ one answer naming two terminals mints two grants. Rulings are live evidence, so stored grants recheck the answer's revision before spending; editing the answer stops its existing grant from covering the action. -Stored `ask` evidence is collected only while its record is unexpired and -unrevoked. `Asked` also reads recent transcript answers. To stop an already minted +Stored `ask` and `words` evidence is collected only while its record is unexpired +and unrevoked. `Asked` also reads recent transcript answers. To stop an already minted permission, revoke the grant of the declaration's kind. ## Follow the check order diff --git a/tests/test_context.py b/tests/test_context.py index c813648e..7d1f191c 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -639,7 +639,7 @@ def test_root_transcript_block_renders_the_lane_root_and_is_empty_outside_a_lane ) assert HookContext(session=SessionStore(None), transcript=lane, settings=None).root_transcript_block() == "" - def test_root_excerpt_keeps_only_matching_events_from_the_whole_root(self) -> None: + def test_root_excerpt_keeps_only_matching_events_from_the_root(self) -> None: from captain_hook.context import render_window from captain_hook.prompt import Prompt from captain_hook.testing.helpers import fixture_file, fixture_session @@ -684,6 +684,19 @@ def test_root_excerpt_keeps_only_matching_events_from_the_whole_root(self) -> No outside = HookContext(session=SessionStore(None), transcript=ctx.transcript, settings=None) assert outside.root_excerpt(["x"]) is None + def test_root_excerpt_reads_only_the_roots_trailing_window(self) -> None: + from captain_hook.context import render_window + from captain_hook.testing.helpers import fixture_file + from captain_hook.transcripts import root_excerpt + + root = fixture_file( + [T.user("approve the old draft"), *(T.user(f"status {i}") for i in range(50)), T.user("approve the new")] + ) + last = len(root.read_bytes().splitlines(keepends=True)[-1]) + for tail_bytes in (last, last + 10): + excerpt = root_excerpt(root, ["approve"], around=0, tail_bytes=tail_bytes) + assert render_window(excerpt, window=None, tool_results=False, budget=None) == "user: approve the new" + def test_root_excerpt_keeps_an_answer_that_later_agent_echoes_outnumber(self) -> None: from captain_hook.testing.helpers import fixture_file from captain_hook.transcripts import root_excerpt diff --git a/tests/test_grants.py b/tests/test_grants.py index 429de8f6..e4d6ffa7 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -714,3 +714,29 @@ def test_spending_an_unknown_grant_names_it(tmp_path: Path) -> None: grant_cli, [*argv, "--session", TREE, "--call", "p", "--fingerprint", "f", "--summary", "s"] ) assert refused.exit_code == 1 and "no grant 000000000000" in refused.output + + +def recorded_words(quote: str, *, expires: datetime) -> Grant: + said = evidence_module.words_evidence(quote, store.now() - timedelta(days=2)) + return store.mint( + Grant( + id=store.new_id(), + kind="words", + tree=TREE, + scope={}, + evidence=[said], + source_key=said.key, + expires=expires, + author="words@test", + created=store.now() - timedelta(days=2), + ) + ) + + +def test_a_lane_reads_its_roots_older_words_from_the_recorded_index(tmp_path: Path) -> None: + recorded_words("post the AIG summary in that thread", expires=store.now() + timedelta(days=5)) + recorded_words("post anything anywhere", expires=store.now() - timedelta(minutes=1)) + lane = event(tmp_path, session="lane-session") + lane.ctx.root_path = tmp_path / f"{TREE}.jsonl" + quotes = [item.quote for item in OwnerWords().collect(lane, proposal(lane))] + assert quotes == ["post the AIG summary in that thread"] diff --git a/tests/test_snapshot_owner.py b/tests/test_snapshot_owner.py index 421dcb38..8fd0f5f5 100644 --- a/tests/test_snapshot_owner.py +++ b/tests/test_snapshot_owner.py @@ -1,6 +1,7 @@ import io import json import struct +import sys import threading import time from types import SimpleNamespace @@ -8,7 +9,14 @@ import pytest from jsonschema import ValidationError -from captain_hook.snapshots.client import CORE_SCHEMA, HOST_SCHEMA, MAX_FRAME_BYTES, SnapshotProtocolError, encode_frame +from captain_hook.snapshots.client import ( + CORE_SCHEMA, + HOST_SCHEMA, + MAX_FRAME_BYTES, + Bridge, + SnapshotProtocolError, + encode_frame, +) from captain_hook.snapshots.validation import checked, validator from captain_hook.snapshots.worker import OWNER_ADMISSION, OwnerService, empty_usage, failure, handshake, read_frame @@ -509,3 +517,25 @@ def native_call(body, *, context, cancellation): assert captured[0]["registry_generation"] == "content-fingerprint" assert captured[0]["authority"] == authority assert captured[0]["claimant"] == "fixture" + + +REFUSING_HOST = """ +import json, struct, sys +def read(): + (size,) = struct.unpack(">I", sys.stdin.buffer.read(4)) + return json.loads(sys.stdin.buffer.read(size)) +def write(value): + body = json.dumps(value).encode() + sys.stdout.buffer.write(struct.pack(">I", len(body)) + body) + sys.stdout.buffer.flush() +write(read()) +frame = read() +write({"protocol": 1, "op": "error", "id": frame["id"], "error": "captain: snapshot admission queue exhausted"}) +""" + + +def test_the_bridge_names_the_error_the_host_returns(): + bridge = Bridge((sys.executable, "-c", REFUSING_HOST)) + with pytest.raises(SnapshotProtocolError, match="snapshot host failed the request: captain: snapshot admission"): + bridge({"schema": HOST_SCHEMA}) + bridge.close() From 5fcb7c7deef6ddad7b1c218d63f35645e019429b Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 22:34:01 -0700 Subject: [PATCH 2/5] =?UTF-8?q?grants:=20=E2=9C=A8=20Adopt=20the=20Orca=20?= =?UTF-8?q?Run=20coordinator's=20grants=20into=20its=20lanes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record each attended session's Orca terminal in the grant store, once per session. Resolve a lane's Run and coordinator terminal with orca orchestration worker-list and run-show. Adopt the coordinator tree's grants into the lane tree, logged as agent orca:. Cache the Run lookup per session; outside Orca nothing binds. Scrub ambient Orca session variables in the test suite. --- .../builtin_packs/general/hooks/grants.py | 11 ++ captain_hook/grants/declare.py | 2 + captain_hook/grants/orca.py | 107 ++++++++++++++++++ captain_hook/grants/store.py | 37 ++++++ docs/guide/grants.qmd | 14 +++ tests/conftest.py | 2 + tests/test_grants.py | 59 ++++++++++ 7 files changed, 232 insertions(+) create mode 100644 captain_hook/grants/orca.py diff --git a/captain_hook/builtin_packs/general/hooks/grants.py b/captain_hook/builtin_packs/general/hooks/grants.py index afc6b71f..8d27d30a 100644 --- a/captain_hook/builtin_packs/general/hooks/grants.py +++ b/captain_hook/builtin_packs/general/hooks/grants.py @@ -6,6 +6,7 @@ from captain_hook import Allow, Block, Event, Input, LambdaCondition, Tool, on from captain_hook.grants import store from captain_hook.grants.evidence import answer_evidence, machine_written, parse_answer, tree_of, words_evidence +from captain_hook.grants.orca import record_terminal from captain_hook.grants.records import Evidence, Grant if TYPE_CHECKING: @@ -54,6 +55,16 @@ def record_owner_words(evt: UserPromptSubmitEvent) -> HookResult | None: return None +@on( + Event.SessionStart | Event.UserPromptSubmit | Event.PreToolUse, + respect_gitignore=False, + skip_planning_agents=False, +) +def record_orca_terminal(evt: BaseHookEvent) -> HookResult | None: + record_terminal(evt) + return None + + def mints(evt: BaseHookEvent) -> bool: for call in evt.cmd.calls(): words = [word.value or "" for word in call.command.words] diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index 8143a020..e475b41a 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -17,6 +17,7 @@ from captain_hook.grants import store from captain_hook.grants.evidence import Asked, OwnerWords, tree_of, verbatim from captain_hook.grants.judge import GrantVerdict, Judge, JudgeFailed +from captain_hook.grants.orca import adopt_coordinator from captain_hook.grants.records import Allowed, Denied, Evidence, Grant, Proposal if TYPE_CHECKING: @@ -180,6 +181,7 @@ def check(self, evt: BaseHookEvent, action: Proposal | None = None) -> Allowed | action = self.action(evt) scope = self.canonical(action) tree = tree_of(evt) + adopt_coordinator(evt) collected: list[Evidence] | None = None def session() -> list[Evidence]: diff --git a/captain_hook/grants/orca.py b/captain_hook/grants/orca.py new file mode 100644 index 00000000..edbf8a67 --- /dev/null +++ b/captain_hook/grants/orca.py @@ -0,0 +1,107 @@ +"""An Orca lane reads its Run coordinator's grants, bound through Orca's own orchestration record.""" + +from __future__ import annotations + +import json +import subprocess +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from loguru import logger +from pydantic import BaseModel + +from captain_hook.grants import store +from captain_hook.grants.evidence import tree_of +from captain_hook.util import reqenv + +if TYPE_CHECKING: + from captain_hook.events import BaseHookEvent + +ORCA_TIMEOUT = 5 +UNBOUND_RECHECK = timedelta(minutes=5) +WORKER_PAGE = 100 + + +class OrcaRun(BaseModel): + run: str | None = None + coordinator: str | None = None + checked: datetime | None = None + + +def terminal() -> str | None: + return reqenv.getenv("ORCA_TERMINAL_HANDLE") or None + + +def attended() -> bool: + return reqenv.getenv("CLAUDE_CODE_SESSION_ATTENDED") == "1" + + +def orca(*args: str) -> dict[str, Any] | None: + try: + done = subprocess.run( + ["orca", *args, "--json"], + capture_output=True, + text=True, + timeout=ORCA_TIMEOUT, + env=reqenv.env_map(), + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if done.returncode != 0 or not done.stdout.strip(): + return None + return json.loads(done.stdout)["result"] + + +def dispatched_run(handle: str) -> str | None: + cursor: list[str] = [] + while (page := orca("orchestration", "worker-list", "--limit", str(WORKER_PAGE), *cursor)) is not None: + for worker in page["workers"]: + if worker["agentTerminalHandle"] == handle and worker["dispatchStatus"] == "dispatched": + return worker["runId"] + if not page["page"]["hasMore"]: + return None + cursor = ["--cursor", page["page"]["nextCursor"]] + return None + + +def resolve(handle: str) -> OrcaRun: + at = store.now() + if (run := dispatched_run(handle)) is None or (shown := orca("orchestration", "run-show", "--id", run)) is None: + return OrcaRun(checked=at) + return OrcaRun(run=run, coordinator=shown["run"]["coordinator_handle"], checked=at) + + +def bound_run(evt: BaseHookEvent, handle: str) -> OrcaRun: + slot = evt.ctx.session[OrcaRun] + cached = slot.get() + if cached is not None and ( + cached.run is not None or (cached.checked is not None and store.now() - cached.checked < UNBOUND_RECHECK) + ): + return cached + found = resolve(handle) + slot.set(found) + return found + + +def record_terminal(evt: BaseHookEvent) -> None: + if (handle := terminal()) is None or not attended() or not evt.ctx.session.once(handle, scope="orca-terminal"): + return + if store.record_terminal(handle, tree_of(evt)): + logger.bind(terminal=handle, tree=tree_of(evt)).info("recorded the orca terminal's session tree") + + +def adopt_coordinator(evt: BaseHookEvent) -> None: + if (handle := terminal()) is None: + return + binding = bound_run(evt, handle) + if binding.run is None or binding.coordinator is None: + return + tree = tree_of(evt) + if (coordinator := store.terminal_tree(binding.coordinator)) is None or coordinator == tree: + return + agent = f"orca:{binding.run}" + if adopted := store.adopt_tree(coordinator, tree=tree, session=evt.session_id, agent=agent): + logger.bind(run=binding.run, coordinator=coordinator, tree=tree, adopted=adopted).info( + "adopted the orca coordinator's grants" + ) diff --git a/captain_hook/grants/store.py b/captain_hook/grants/store.py index a3391f8b..a56e281d 100644 --- a/captain_hook/grants/store.py +++ b/captain_hook/grants/store.py @@ -45,6 +45,12 @@ agent TEXT NOT NULL, PRIMARY KEY (grant_id, tree) ); +CREATE TABLE IF NOT EXISTS orca_terminals ( + handle TEXT NOT NULL, + tree TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (handle, tree) +); CREATE INDEX IF NOT EXISTS grants_by_kind ON grants (kind, tree); CREATE INDEX IF NOT EXISTS spends_by_grant ON spends (grant_id); CREATE INDEX IF NOT EXISTS spends_by_call ON spends (tool_use_id, state); @@ -303,6 +309,37 @@ def adopt(grant_id: str, *, tree: str, session: str, agent: str) -> Adoption: return adoption +def adopt_tree(source: str, *, tree: str, session: str, agent: str) -> int: + """Adopt every grant minted in *source* into *tree*, logged like :func:`adopt`; returns how many were new.""" + with connect() as db: + return db.execute( + "INSERT OR IGNORE INTO adoptions (grant_id, tree, at, session, agent)" + " SELECT id, ?, ?, ?, ? FROM grants WHERE tree = ?", + (tree, now().isoformat(), session, agent, source), + ).rowcount + + +def record_terminal(handle: str, tree: str) -> bool: + """Record that session tree *tree* runs in the Orca terminal *handle*; ``False`` when already recorded.""" + with connect() as db: + return ( + db.execute( + "INSERT OR IGNORE INTO orca_terminals (handle, tree, at) VALUES (?, ?, ?)", + (handle, tree, now().isoformat()), + ).rowcount + == 1 + ) + + +def terminal_tree(handle: str) -> str | None: + """The session tree most recently recorded in the Orca terminal *handle*.""" + with connect() as db: + row = db.execute( + "SELECT tree FROM orca_terminals WHERE handle = ? ORDER BY at DESC LIMIT 1", (handle,) + ).fetchone() + return None if row is None else row[0] + + def adoptions(grant_id: str) -> list[Adoption]: with connect() as db: rows = db.execute( diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index 67b38806..534dd447 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -492,6 +492,20 @@ The CLI reads the adopting session from the session environment variables even when `--tree` is explicit. `grant show` logs each adoption with its destination tree, timestamp, session, and agent. +An Orca lane adopts its Run coordinator's grants without a command. The builtin +`general` pack records the Orca terminal of each attended Claude session from +`ORCA_TERMINAL_HANDLE`, once per session. A `claude -p` session is unattended and +records nothing. + +On a grants check in an Orca terminal, the framework finds the +dispatched worker for that terminal with `orca orchestration worker-list`, and reads +the Run's coordinator terminal with `orca orchestration run-show`. It then adopts +every grant minted in the session tree last recorded for that terminal. Each +adoption is logged with agent `orca:`, so both trees share the budget, and +`grant show` lists the lane. The session caches the Run lookup; a terminal with no +dispatched worker is looked up again after five minutes. Outside Orca, or with no +recorded coordinator session, nothing is adopted. + Inspect and revoke a grant with its printed id in place of `GRANT_ID`: ```bash diff --git a/tests/conftest.py b/tests/conftest.py index 4163bdb1..269da668 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,6 +89,8 @@ def clean_state(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.Mo # The SessionEnd reviewer skips headless entrypoints (sdk-*); scrub it so tests don't # inherit the ambient CLAUDE_CODE_ENTRYPOINT of a pytest run launched inside claude. monkeypatch.delenv("CLAUDE_CODE_ENTRYPOINT", raising=False) + monkeypatch.delenv("ORCA_TERMINAL_HANDLE", raising=False) + monkeypatch.delenv("CLAUDE_CODE_SESSION_ATTENDED", raising=False) config_dir = tmp_path_factory.mktemp("claude-config") (config_dir / "plugins").mkdir() monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) diff --git a/tests/test_grants.py b/tests/test_grants.py index e4d6ffa7..26d1dc85 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -35,6 +35,7 @@ ) from captain_hook.grants import cli as grant_cli_module from captain_hook.grants import evidence as evidence_module +from captain_hook.grants import orca as orca_module from captain_hook.grants.cli import grant as grant_cli from captain_hook.hook_lint import result_violations from captain_hook.types import Action, HookResult, HookSpec, RegisteredHook @@ -740,3 +741,61 @@ def test_a_lane_reads_its_roots_older_words_from_the_recorded_index(tmp_path: Pa lane.ctx.root_path = tmp_path / f"{TREE}.jsonl" quotes = [item.quote for item in OwnerWords().collect(lane, proposal(lane))] assert quotes == ["post the AIG summary in that thread"] + + +def in_orca(monkeypatch: pytest.MonkeyPatch, handle: str) -> None: + monkeypatch.setenv("ORCA_TERMINAL_HANDLE", handle) + monkeypatch.setenv("CLAUDE_CODE_SESSION_ATTENDED", "1") + + +def fake_orca(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, ...]]: + calls: list[tuple[str, ...]] = [] + worker = {"agentTerminalHandle": "term_lane", "dispatchStatus": "dispatched", "runId": "run_1"} + finished = {"agentTerminalHandle": "term_old", "dispatchStatus": "completed", "runId": "run_0"} + + def orca(*args: str) -> dict[str, Any]: + calls.append(args) + if args[:2] == ("orchestration", "worker-list"): + return {"workers": [finished, worker], "page": {"hasMore": False, "nextCursor": None}} + assert args == ("orchestration", "run-show", "--id", "run_1") + return {"run": {"id": "run_1", "coordinator_handle": "term_root"}} + + monkeypatch.setattr(orca_module, "orca", orca) + return calls + + +def test_an_attended_orca_session_records_its_terminal_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + in_orca(monkeypatch, "term_root") + evt = event(tmp_path) + orca_module.record_terminal(evt) + orca_module.record_terminal(evt) + assert store.terminal_tree("term_root") == TREE + assert not store.record_terminal("term_root", TREE) + monkeypatch.setenv("CLAUDE_CODE_SESSION_ATTENDED", "0") + monkeypatch.setenv("ORCA_TERMINAL_HANDLE", "term_nested") + orca_module.record_terminal(event(tmp_path / "nested", session="nested-print")) + assert store.terminal_tree("term_nested") is None + + +def test_an_orca_lane_spends_its_coordinators_grant(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store.record_terminal("term_root", TREE) + grant = minted(uses=2) + calls = fake_orca(monkeypatch) + in_orca(monkeypatch, "term_lane") + lane = event(tmp_path / "lane", "lane", session="lane-root") + assert declared().check(lane) + assert [(found.tree, found.agent) for found in store.adoptions(grant.id)] == [("lane-root", "orca:run_1")] + assert declared().check(event(tmp_path / "lane", "again", session="lane-root", call="c2")) + assert len(calls) == 2 + assert not declared().check(event(tmp_path, "root", call="c3")) + + +def test_no_orca_binds_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store.record_terminal("term_root", TREE) + minted(uses=None) + calls = fake_orca(monkeypatch) + monkeypatch.delenv("ORCA_TERMINAL_HANDLE", raising=False) + assert not declared().check(event(tmp_path, session="lane-root")) + in_orca(monkeypatch, "term_unknown") + assert not declared().check(event(tmp_path / "other", session="other-root", call="c2")) + assert calls == [("orchestration", "worker-list", "--limit", "100")] From 9005149b1dc75db409c76c9951f30cb8f64ce212 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 23:07:42 -0700 Subject: [PATCH 3/5] =?UTF-8?q?grants:=20=F0=9F=90=9B=20Keep=20Orca=20adop?= =?UTF-8?q?tion=20to=20spendable=20grants=20and=20bind=20it=20to=20a=20pin?= =?UTF-8?q?ned,=20revalidated=20coordinator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt only spendable grants, never the coordinator's recorded words or asks. Pin the coordinator session per Run and terminal; a reused terminal lends nothing. Revalidate the Run binding after one minute or a terminal change. Pass expired owner records said after a grant to its judge, so a withdrawal never lapses. --- captain_hook/grants/declare.py | 16 ++++--- captain_hook/grants/evidence.py | 19 +++++++++ captain_hook/grants/orca.py | 41 +++++++++++------- captain_hook/grants/store.py | 11 +++-- tests/test_grants.py | 74 ++++++++++++++++++++++++++++++--- 5 files changed, 133 insertions(+), 28 deletions(-) diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index e475b41a..a8dec151 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -15,7 +15,7 @@ from loguru import logger from captain_hook.grants import store -from captain_hook.grants.evidence import Asked, OwnerWords, tree_of, verbatim +from captain_hook.grants.evidence import Asked, OwnerWords, lapsed, tree_of, verbatim from captain_hook.grants.judge import GrantVerdict, Judge, JudgeFailed from captain_hook.grants.orca import adopt_coordinator from captain_hook.grants.records import Allowed, Denied, Evidence, Grant, Proposal @@ -202,11 +202,15 @@ def session() -> list[Evidence]: refusals.append(f"grant {grant.id} rests on {stale}, which changed after the grant was minted.") continue reason, relied = f"covered by grant {grant.id}", [item.id for item in grant.evidence] - since = [ - item - for item in (session() if self.judge is not None else ()) - if item.source in OWNER_SOURCES and item.said_at is not None and item.said_at > grant.created - ] + later = [*session(), *lapsed(evt, grant.created)] if self.judge is not None else [] + since = sorted( + { + item.id: item + for item in later + if item.source in OWNER_SOURCES and item.said_at is not None and item.said_at > grant.created + }.values(), + key=lambda item: item.said_at.timestamp() if item.said_at else 0.0, + ) allowed_by_rule = any(ruling.verdict == "allow" for ruling in rulings) if self.judge is not None and (since or not allowed_by_rule): try: diff --git a/captain_hook/grants/evidence.py b/captain_hook/grants/evidence.py index 1a5625ea..658ed17a 100644 --- a/captain_hook/grants/evidence.py +++ b/captain_hook/grants/evidence.py @@ -177,6 +177,25 @@ def recorded(evt: BaseHookEvent, kind: str) -> list[Evidence]: ] +def lapsed(evt: BaseHookEvent, since: datetime) -> list[Evidence]: + """The owner's recorded words and answers in *evt*'s tree said after *since* whose records have expired. + + A grant minted before them still reaches the judge with them, so an expired record never ends a + withdrawal while the grant it withdrew stays usable. + """ + from captain_hook.grants import store + + at = store.now() + return [ + item + for kind in store.EVIDENCE_KINDS + for grant in store.grants(kind, tree_of(evt)) + if grant.revoked is None and grant.expires is not None and grant.expires <= at + for item in grant.evidence + if item.said_at is not None and item.said_at > since + ] + + @dataclass(frozen=True, slots=True) class Asked: """The owner's AskUserQuestion answers in the session tree, with every option and preview they saw.""" diff --git a/captain_hook/grants/orca.py b/captain_hook/grants/orca.py index edbf8a67..c7da2f10 100644 --- a/captain_hook/grants/orca.py +++ b/captain_hook/grants/orca.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from loguru import logger -from pydantic import BaseModel +from pydantic import BaseModel, Field from captain_hook.grants import store from captain_hook.grants.evidence import tree_of @@ -18,14 +18,16 @@ from captain_hook.events import BaseHookEvent ORCA_TIMEOUT = 5 -UNBOUND_RECHECK = timedelta(minutes=5) +BINDING_TTL = timedelta(minutes=1) WORKER_PAGE = 100 class OrcaRun(BaseModel): + terminal: str | None = None run: str | None = None coordinator: str | None = None checked: datetime | None = None + pinned: dict[str, str] = Field(default_factory=dict[str, str]) def terminal() -> str | None: @@ -65,25 +67,38 @@ def dispatched_run(handle: str) -> str | None: return None -def resolve(handle: str) -> OrcaRun: - at = store.now() +def resolve(handle: str) -> tuple[str | None, str | None]: if (run := dispatched_run(handle)) is None or (shown := orca("orchestration", "run-show", "--id", run)) is None: - return OrcaRun(checked=at) - return OrcaRun(run=run, coordinator=shown["run"]["coordinator_handle"], checked=at) + return None, None + return run, shown["run"]["coordinator_handle"] + + +def pin(run: str | None, coordinator: str | None) -> str: + return f"{run} {coordinator}" def bound_run(evt: BaseHookEvent, handle: str) -> OrcaRun: slot = evt.ctx.session[OrcaRun] - cached = slot.get() - if cached is not None and ( - cached.run is not None or (cached.checked is not None and store.now() - cached.checked < UNBOUND_RECHECK) - ): + at = store.now() + cached = slot.get(OrcaRun()) + if cached.terminal == handle and cached.checked is not None and at - cached.checked < BINDING_TTL: return cached - found = resolve(handle) + run, coordinator = resolve(handle) + pinned = cached.pinned + if run is not None and coordinator is not None and pin(run, coordinator) not in pinned: + if (tree := store.terminal_tree(coordinator)) is not None: + pinned = pinned | {pin(run, coordinator): tree} + found = OrcaRun(terminal=handle, run=run, coordinator=coordinator, checked=at, pinned=pinned) slot.set(found) return found +def coordinator_tree(binding: OrcaRun) -> str | None: + if binding.coordinator is None or (pinned := binding.pinned.get(pin(binding.run, binding.coordinator))) is None: + return None + return pinned if store.terminal_tree(binding.coordinator) == pinned else None + + def record_terminal(evt: BaseHookEvent) -> None: if (handle := terminal()) is None or not attended() or not evt.ctx.session.once(handle, scope="orca-terminal"): return @@ -95,10 +110,8 @@ def adopt_coordinator(evt: BaseHookEvent) -> None: if (handle := terminal()) is None: return binding = bound_run(evt, handle) - if binding.run is None or binding.coordinator is None: - return tree = tree_of(evt) - if (coordinator := store.terminal_tree(binding.coordinator)) is None or coordinator == tree: + if (coordinator := coordinator_tree(binding)) is None or coordinator == tree: return agent = f"orca:{binding.run}" if adopted := store.adopt_tree(coordinator, tree=tree, session=evt.session_id, agent=agent): diff --git a/captain_hook/grants/store.py b/captain_hook/grants/store.py index a56e281d..1e84f29d 100644 --- a/captain_hook/grants/store.py +++ b/captain_hook/grants/store.py @@ -15,6 +15,7 @@ from captain_hook.util.paths import resolve_state_dir RESERVATION_TTL = timedelta(minutes=2) +EVIDENCE_KINDS = ("ask", "words") SCHEMA = """ CREATE TABLE IF NOT EXISTS grants ( id TEXT PRIMARY KEY, @@ -310,12 +311,16 @@ def adopt(grant_id: str, *, tree: str, session: str, agent: str) -> Adoption: def adopt_tree(source: str, *, tree: str, session: str, agent: str) -> int: - """Adopt every grant minted in *source* into *tree*, logged like :func:`adopt`; returns how many were new.""" + """Adopt every spendable grant minted in *source* into *tree*, logged like :func:`adopt`. + + The owner's recorded words and answers stay in their own tree, so a second tree never mints a fresh + budget from an approval *source* already spent. Returns how many adoptions were new. + """ with connect() as db: return db.execute( "INSERT OR IGNORE INTO adoptions (grant_id, tree, at, session, agent)" - " SELECT id, ?, ?, ?, ? FROM grants WHERE tree = ?", - (tree, now().isoformat(), session, agent, source), + " SELECT id, ?, ?, ?, ? FROM grants WHERE tree = ? AND kind NOT IN (SELECT value FROM json_each(?))", + (tree, now().isoformat(), session, agent, source, json.dumps(EVIDENCE_KINDS)), ).rowcount diff --git a/tests/test_grants.py b/tests/test_grants.py index 26d1dc85..ea60f18b 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -717,8 +717,8 @@ def test_spending_an_unknown_grant_names_it(tmp_path: Path) -> None: assert refused.exit_code == 1 and "no grant 000000000000" in refused.output -def recorded_words(quote: str, *, expires: datetime) -> Grant: - said = evidence_module.words_evidence(quote, store.now() - timedelta(days=2)) +def recorded_words(quote: str, *, expires: datetime, ago: timedelta = timedelta(days=2)) -> Grant: + said = evidence_module.words_evidence(quote, store.now() - ago) return store.mint( Grant( id=store.new_id(), @@ -729,7 +729,7 @@ def recorded_words(quote: str, *, expires: datetime) -> Grant: source_key=said.key, expires=expires, author="words@test", - created=store.now() - timedelta(days=2), + created=store.now() - ago, ) ) @@ -748,14 +748,15 @@ def in_orca(monkeypatch: pytest.MonkeyPatch, handle: str) -> None: monkeypatch.setenv("CLAUDE_CODE_SESSION_ATTENDED", "1") -def fake_orca(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, ...]]: +def fake_orca(monkeypatch: pytest.MonkeyPatch, status: list[str] | None = None) -> list[tuple[str, ...]]: calls: list[tuple[str, ...]] = [] - worker = {"agentTerminalHandle": "term_lane", "dispatchStatus": "dispatched", "runId": "run_1"} + lane = status or ["dispatched"] finished = {"agentTerminalHandle": "term_old", "dispatchStatus": "completed", "runId": "run_0"} def orca(*args: str) -> dict[str, Any]: calls.append(args) if args[:2] == ("orchestration", "worker-list"): + worker = {"agentTerminalHandle": "term_lane", "dispatchStatus": lane[0], "runId": "run_1"} return {"workers": [finished, worker], "page": {"hasMore": False, "nextCursor": None}} assert args == ("orchestration", "run-show", "--id", "run_1") return {"run": {"id": "run_1", "coordinator_handle": "term_root"}} @@ -799,3 +800,66 @@ def test_no_orca_binds_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) in_orca(monkeypatch, "term_unknown") assert not declared().check(event(tmp_path / "other", session="other-root", call="c2")) assert calls == [("orchestration", "worker-list", "--limit", "100")] + + +def test_an_orca_lane_never_adopts_the_coordinators_recorded_words( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store.record_terminal("term_root", TREE) + words = recorded_words("send one reply in that thread", expires=store.now() + timedelta(days=5)) + grant = minted(uses=1) + fake_orca(monkeypatch) + in_orca(monkeypatch, "term_lane") + assert declared().check(event(tmp_path / "a", "a", session="lane-a")) + assert store.adoptions(words.id) == [] and [found.tree for found in store.adoptions(grant.id)] == ["lane-a"] + judged = declared(judge=Judge("rules"), evidence=(OwnerWords(),)) + lane_b = event(tmp_path / "b", "b", session="lane-b", call="c2", allow=True, reason="ok", relied_on=[words.id]) + assert evidence_module.recorded(lane_b, "words") == [] + assert not judged.check(lane_b) + assert not declared().check(event(tmp_path, "root", call="c3")) + + +def test_a_reused_coordinator_terminal_never_lends_the_new_sessions_grants( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store.record_terminal("term_root", TREE) + minted(uses=None) + fake_orca(monkeypatch) + in_orca(monkeypatch, "term_lane") + assert declared().check(event(tmp_path / "lane", "lane", session="lane-root")) + store.record_terminal("term_root", "unrelated-root") + unrelated = store.mint( + Grant(id=store.new_id(), kind="test.write", tree="unrelated-root", scope=SCOPE, author="t", created=store.now()) + ) + monkeypatch.setattr(orca_module, "BINDING_TTL", timedelta(0)) + declared().check(event(tmp_path / "lane", "again", session="lane-root", call="c2")) + assert store.adoptions(unrelated.id) == [] + + +def test_an_orca_binding_is_revalidated_after_its_ttl(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + store.record_terminal("term_root", TREE) + first = minted(uses=None) + status = ["dispatched"] + calls = fake_orca(monkeypatch, status) + in_orca(monkeypatch, "term_lane") + assert declared().check(event(tmp_path / "lane", "lane", session="lane-root")) + assert [found.tree for found in store.adoptions(first.id)] == ["lane-root"] + monkeypatch.setenv("ORCA_TERMINAL_HANDLE", "term_moved") + declared().check(event(tmp_path / "lane", "moved", session="lane-root", call="c2")) + assert len(calls) == 3 + monkeypatch.setenv("ORCA_TERMINAL_HANDLE", "term_lane") + monkeypatch.setattr(orca_module, "BINDING_TTL", timedelta(0)) + status[0] = "completed" + later = minted(uses=None) + declared().check(event(tmp_path / "lane", "done", session="lane-root", call="c3")) + assert store.adoptions(later.id) == [] + + +def test_a_withdrawal_whose_record_expired_still_reaches_the_judge(tmp_path: Path) -> None: + grant = minted(approved={"text": "ok"}, uses=None) + recorded_words("stop posting there", expires=store.now() - timedelta(seconds=1), ago=timedelta(seconds=30)) + evt = event(tmp_path, "ok", allow=False, reason="the owner withdrew it", withdrawn=True) + denied = declared(rules=(ContentMatches(),), judge=Judge("rules"), evidence=(OwnerWords(),)).check(evt) + assert isinstance(denied, Denied) and "withdrew" in denied.reason + assert "stop posting there" in evt.ctx.call_llm.call_args_list[0].args[0].system_text + assert store.load(grant.id).revoked is not None From 97d541eb2eadcaafeaa4aa6ba7c76da3dda626a9 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 23:08:04 -0700 Subject: [PATCH 4/5] =?UTF-8?q?grants:=20=F0=9F=93=9D=20Document=20pinned?= =?UTF-8?q?=20Orca=20bindings=20and=20lapsed=20withdrawal=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/grants.qmd | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index 534dd447..528563a6 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -261,7 +261,9 @@ stored grants recheck the answer's revision before spending; editing the answer stops its existing grant from covering the action. Stored `ask` and `words` evidence is collected only while its record is unexpired -and unrevoked. `Asked` also reads recent transcript answers. To stop an already minted +and unrevoked. The judge of a stored grant also reads expired records said after the +grant was minted, so a withdrawal never lapses while the grant is usable. `Asked` +also reads recent transcript answers. To stop an already minted permission, revoke the grant of the declaration's kind. ## Follow the check order @@ -500,11 +502,16 @@ records nothing. On a grants check in an Orca terminal, the framework finds the dispatched worker for that terminal with `orca orchestration worker-list`, and reads the Run's coordinator terminal with `orca orchestration run-show`. It then adopts -every grant minted in the session tree last recorded for that terminal. Each -adoption is logged with agent `orca:`, so both trees share the budget, and -`grant show` lists the lane. The session caches the Run lookup; a terminal with no -dispatched worker is looked up again after five minutes. Outside Orca, or with no -recorded coordinator session, nothing is adopted. +every spendable grant minted in the session tree recorded for that terminal. The +coordinator's recorded `words` and `ask` records are never adopted, so one approval +still spends once. Each adoption is logged with agent `orca:`, so both trees +share the budget, and `grant show` lists the lane. + +A lane pins the coordinator session it first binds for each Run. When the coordinator +terminal's record moves to another session, the lane adopts nothing more from it. +The session re-reads the Run from Orca after one minute, or when its terminal +changes, so a finished worker stops adopting. Outside Orca, or with no recorded +coordinator session, nothing is adopted. Inspect and revoke a grant with its printed id in place of `GRANT_ID`: From 8cccc06fb329bed9bd76624e1dc23b4e08ad80f2 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 23:17:48 -0700 Subject: [PATCH 5/5] =?UTF-8?q?grants:=20=F0=9F=90=9B=20Read=20the=20lane'?= =?UTF-8?q?s=20Run=20through=20the=20sessions=20pack's=20Orca=20readers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve a lane's dispatch with the sessions pack's worker_of and orca_json, which page Orca's worker list unscoped and tolerate its page shapes, instead of a second Orca client. Stub the binding in the sessions recheck test, which scripts each worker_of reply. --- captain_hook/grants/orca.py | 44 +++++++------------------------------ tests/test_grants.py | 38 ++++++++++++++++++++------------ tests/test_pack_sessions.py | 2 ++ 3 files changed, 34 insertions(+), 50 deletions(-) diff --git a/captain_hook/grants/orca.py b/captain_hook/grants/orca.py index c7da2f10..25688e02 100644 --- a/captain_hook/grants/orca.py +++ b/captain_hook/grants/orca.py @@ -2,10 +2,8 @@ from __future__ import annotations -import json -import subprocess from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from loguru import logger from pydantic import BaseModel, Field @@ -17,9 +15,7 @@ if TYPE_CHECKING: from captain_hook.events import BaseHookEvent -ORCA_TIMEOUT = 5 BINDING_TTL = timedelta(minutes=1) -WORKER_PAGE = 100 class OrcaRun(BaseModel): @@ -38,39 +34,15 @@ def attended() -> bool: return reqenv.getenv("CLAUDE_CODE_SESSION_ATTENDED") == "1" -def orca(*args: str) -> dict[str, Any] | None: - try: - done = subprocess.run( - ["orca", *args, "--json"], - capture_output=True, - text=True, - timeout=ORCA_TIMEOUT, - env=reqenv.env_map(), - check=False, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - if done.returncode != 0 or not done.stdout.strip(): - return None - return json.loads(done.stdout)["result"] - - -def dispatched_run(handle: str) -> str | None: - cursor: list[str] = [] - while (page := orca("orchestration", "worker-list", "--limit", str(WORKER_PAGE), *cursor)) is not None: - for worker in page["workers"]: - if worker["agentTerminalHandle"] == handle and worker["dispatchStatus"] == "dispatched": - return worker["runId"] - if not page["page"]["hasMore"]: - return None - cursor = ["--cursor", page["page"]["nextCursor"]] - return None - - def resolve(handle: str) -> tuple[str | None, str | None]: - if (run := dispatched_run(handle)) is None or (shown := orca("orchestration", "run-show", "--id", run)) is None: + from captain_hook.builtin_packs.general.hooks._sessions import orca_json, worker_of + + worker = worker_of(handle) + if not isinstance(worker, dict) or worker.get("dispatchStatus") != "dispatched" or not (run := worker.get("runId")): return None, None - return run, shown["run"]["coordinator_handle"] + shown = ("orca", "orchestration", "run-show", "--id", run, "--json") + coordinator = orca_json(shown, "result", "run", "coordinator_handle") + return (run, coordinator) if isinstance(coordinator, str) else (None, None) def pin(run: str | None, coordinator: str | None) -> str: diff --git a/tests/test_grants.py b/tests/test_grants.py index ea60f18b..e4d08751 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -748,20 +748,15 @@ def in_orca(monkeypatch: pytest.MonkeyPatch, handle: str) -> None: monkeypatch.setenv("CLAUDE_CODE_SESSION_ATTENDED", "1") -def fake_orca(monkeypatch: pytest.MonkeyPatch, status: list[str] | None = None) -> list[tuple[str, ...]]: - calls: list[tuple[str, ...]] = [] +def fake_orca(monkeypatch: pytest.MonkeyPatch, status: list[str] | None = None) -> list[str]: + calls: list[str] = [] lane = status or ["dispatched"] - finished = {"agentTerminalHandle": "term_old", "dispatchStatus": "completed", "runId": "run_0"} - def orca(*args: str) -> dict[str, Any]: - calls.append(args) - if args[:2] == ("orchestration", "worker-list"): - worker = {"agentTerminalHandle": "term_lane", "dispatchStatus": lane[0], "runId": "run_1"} - return {"workers": [finished, worker], "page": {"hasMore": False, "nextCursor": None}} - assert args == ("orchestration", "run-show", "--id", "run_1") - return {"run": {"id": "run_1", "coordinator_handle": "term_root"}} + def resolve(handle: str) -> tuple[str | None, str | None]: + calls.append(handle) + return ("run_1", "term_root") if handle == "term_lane" and lane[0] == "dispatched" else (None, None) - monkeypatch.setattr(orca_module, "orca", orca) + monkeypatch.setattr(orca_module, "resolve", resolve) return calls @@ -787,7 +782,7 @@ def test_an_orca_lane_spends_its_coordinators_grant(tmp_path: Path, monkeypatch: assert declared().check(lane) assert [(found.tree, found.agent) for found in store.adoptions(grant.id)] == [("lane-root", "orca:run_1")] assert declared().check(event(tmp_path / "lane", "again", session="lane-root", call="c2")) - assert len(calls) == 2 + assert calls == ["term_lane"] assert not declared().check(event(tmp_path, "root", call="c3")) @@ -799,7 +794,7 @@ def test_no_orca_binds_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) assert not declared().check(event(tmp_path, session="lane-root")) in_orca(monkeypatch, "term_unknown") assert not declared().check(event(tmp_path / "other", session="other-root", call="c2")) - assert calls == [("orchestration", "worker-list", "--limit", "100")] + assert calls == ["term_unknown"] def test_an_orca_lane_never_adopts_the_coordinators_recorded_words( @@ -846,7 +841,7 @@ def test_an_orca_binding_is_revalidated_after_its_ttl(tmp_path: Path, monkeypatc assert [found.tree for found in store.adoptions(first.id)] == ["lane-root"] monkeypatch.setenv("ORCA_TERMINAL_HANDLE", "term_moved") declared().check(event(tmp_path / "lane", "moved", session="lane-root", call="c2")) - assert len(calls) == 3 + assert calls == ["term_lane", "term_moved"] monkeypatch.setenv("ORCA_TERMINAL_HANDLE", "term_lane") monkeypatch.setattr(orca_module, "BINDING_TTL", timedelta(0)) status[0] = "completed" @@ -863,3 +858,18 @@ def test_a_withdrawal_whose_record_expired_still_reaches_the_judge(tmp_path: Pat assert isinstance(denied, Denied) and "withdrew" in denied.reason assert "stop posting there" in evt.ctx.call_llm.call_args_list[0].args[0].system_text assert store.load(grant.id).revoked is not None + + +@pytest.mark.parametrize( + ("status", "expected"), + [("dispatched", ("run_inline", "term_root")), ("completed", (None, None))], + ids=["dispatched", "settled"], +) +def test_a_lane_resolves_its_run_through_orcas_worker_list(status: str, expected: tuple[str | None, ...]) -> None: + from captain_hook.builtin_packs.general.hooks._sessions import inline_run, inline_worker + from captain_hook.testing.helpers import stubbed_commands + + commands = {"orca orchestration worker-list": inline_worker("term_lane", status), **inline_run("term_root")} + with stubbed_commands(commands): + assert orca_module.resolve("term_lane") == expected + assert orca_module.resolve("term_other") == (None, None) diff --git a/tests/test_pack_sessions.py b/tests/test_pack_sessions.py index 749667ac..6c1dd798 100644 --- a/tests/test_pack_sessions.py +++ b/tests/test_pack_sessions.py @@ -34,6 +34,7 @@ from captain_hook.dispatch import SYNC_DEADLINE_MARGIN_SECONDS, dispatch from captain_hook.events import PreToolUseEvent from captain_hook.grants import evidence as evidence_module +from captain_hook.grants import orca as orca_grants from captain_hook.grants import store from captain_hook.loader import discover_pack from captain_hook.session import SessionStore @@ -754,6 +755,7 @@ def test_a_class_lift_rechecks_the_orca_record_after_the_judge_allows( ) -> None: monkeypatch.setattr(_sessions, "idle", lambda handle, seen=iter(idles): next(seen)) monkeypatch.setattr(_sessions, "worker_of", lambda handle, seen=iter(workers): next(seen)) + monkeypatch.setattr(orca_grants, "resolve", lambda handle: (None, None)) with stubbed_commands(SETTLED): envelope = envelope_of(bash(AGENT_CLOSE, llm=CLASS_ALLOW), tmp_path, env=ROOT) assert envelope is not None