From e3a3b43f9f68085a038d1338e340fe8509e647f9 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 25 Sep 2026 20:24:25 -0700 Subject: [PATCH] =?UTF-8?q?captain-hook:=20=E2=99=BB=EF=B8=8F=20Bound=20an?= =?UTF-8?q?d=20warm=20prepared=20transcript=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Deep hook conditions traverse and reread large registered transcript sets. Summary: Use an event-owned prepared graph, cumulative foreground budgets, and paced root and registry warming. Motivation: Keep terminal hooks responsive without losing independent transcript evidence. Details: Pin cc-transcript v14.28.3, route warming and cleanup through bounded admission, add operator prewarming, refresh fresh-release wheel smoke metadata, document the release, and generate protocol schemas. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 5 + captain_hook/cli.py | 203 ++++- captain_hook/snapshots/client.py | 511 ++++++++++- .../snapshots/schemas/config.schema.json | 14 + .../snapshots/schemas/context.schema.json | 9 + .../schemas/host-request.schema.json | 837 +++++++++++++++++- .../schemas/host-response.schema.json | 819 ++++++++++++++++- .../snapshots/schemas/request.schema.json | 629 ++++++++++++- .../snapshots/schemas/response.schema.json | 212 ++++- captain_hook/snapshots/worker.py | 5 +- captain_hook/testing/snapshots.py | 10 + captain_hook/transcripts.py | 61 +- captain_hook/worker/runtime.py | 33 +- captain_hook/worker/service.py | 209 ++++- internal/hookd/snapshot_bridge.go | 13 +- internal/hookd/snapshot_owner.go | 5 + internal/hookd/snapshot_reverse.go | 8 +- internal/hookd/snapshot_test.go | 83 ++ internal/snapshots/schema.go | 1 + internal/snapshots/schema/config.schema.json | 14 + internal/snapshots/schema/context.schema.json | 9 + .../snapshots/schema/host-request.schema.json | 837 +++++++++++++++++- .../schema/host-response.schema.json | 819 ++++++++++++++++- internal/snapshots/schema/request.schema.json | 629 ++++++++++++- .../snapshots/schema/response.schema.json | 212 ++++- pyproject.toml | 2 +- tests/snapshot_review_helpers.py | 1 + tests/test_prepared_graph_evidence.py | 464 ++++++++++ tests/test_snapshot_client.py | 524 ++++++++++- tests/test_snapshot_command_batch.py | 2 +- tests/test_snapshot_lifecycle.py | 5 +- tests/test_snapshot_owner.py | 47 + tests/test_transcripts_registration.py | 331 ++++++- tests/test_worker_graph_warming.py | 388 ++++++++ tests/test_worker_runtime.py | 50 +- tests/test_worker_snapshot_transport.py | 143 ++- uv.lock | 14 +- 38 files changed, 7918 insertions(+), 242 deletions(-) create mode 100644 tests/test_prepared_graph_evidence.py create mode 100644 tests/test_worker_graph_warming.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e4f63b..a0f2ae11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,7 +100,7 @@ jobs: uv build rm -rf .wheel-smoke uv venv --seed .wheel-smoke - uv pip install --python .wheel-smoke/bin/python dist/*.whl + uv pip install --refresh-package cc-transcript --python .wheel-smoke/bin/python dist/*.whl .wheel-smoke/bin/capt-hook --help test -x .wheel-smoke/bin/hook test -x .wheel-smoke/bin/hookd diff --git a/CHANGELOG.md b/CHANGELOG.md index 07be72e5..b5c30f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Transcript-heavy hooks now share bounded evidence and warm it in the background.** + A hook prepares one graph for its conditions, reuses it across synchronous and + background work, and returns without a traceback when evidence is incomplete. + A paced worker warms large registered transcripts and root logs without + rereading completed sources; `capt-hook transcripts warm` prewarms a session. - **The graphite pack asks ccx which lane a repo rides.** A `gt repo init` marker is written once and stays forever, but submitting also needs Graphite's grant on the remote. Without it `gt submit` and every ccx stack diff --git a/captain_hook/cli.py b/captain_hook/cli.py index dba37499..a397dfbe 100644 --- a/captain_hook/cli.py +++ b/captain_hook/cli.py @@ -278,7 +278,7 @@ def dispatch_event( """ from captain_hook.context import HookContext from captain_hook.heartbeat import record_heartbeat - from captain_hook.transcripts import lane_transcript_path, lazy_transcript, registered_paths + from captain_hook.transcripts import lane_transcript_path, lazy_transcript, registered_sources from captain_hook.util import reqenv record_heartbeat(event, raw) @@ -287,31 +287,33 @@ def dispatch_event( if event in TOOL_EVENTS and (parent := raw.get("transcript_path")) and (agent_id := raw.get("agent_id")) else raw.get("transcript_path") ) - attachment_paths: tuple[Path, ...] | None = None - attachment_lock = threading.Lock() - - def attachments() -> tuple[Path, ...]: - nonlocal attachment_paths - - with attachment_lock: - if attachment_paths is None: - attachment_paths = registered_paths(session_dir) - return attachment_paths - + transcript = lazy_transcript( + resolved_path, loader=transcript_loader, attach=lambda: registered_sources(session_dir) + ) + background_transcript = transcript.fork() ctx = HookContext( session=SessionStore(session_dir), - transcript=lazy_transcript(resolved_path, loader=transcript_loader, attach=attachments), + transcript=transcript, settings=_state.settings, project_root=root, ) evt = event.event_class(_raw=raw, ctx=ctx) within_margin = reqenv.deadline_within(SYNC_DEADLINE_MARGIN_SECONDS) - envelope = None if within_margin else dispatch(event, evt, session_dir=session_dir) + try: + envelope = None if within_margin else dispatch(event, evt, session_dir=session_dir) + if within_margin: + transcript.release() + except BaseException: + transcript.release() + background_transcript.release() + raise def background() -> None: - transcript = lazy_transcript(resolved_path, loader=transcript_loader, attach=attachments) - fresh = event.event_class(_raw=raw, ctx=ctx.fork(transcript)) - after_reply(event, fresh, raw, session_dir) + fresh = event.event_class(_raw=raw, ctx=ctx.fork(background_transcript)) + try: + after_reply(event, fresh, raw, session_dir) + finally: + background_transcript.release() return envelope, background @@ -820,6 +822,173 @@ def transcripts_register( click.echo(f" registered {provider} transcript for session {session_id}: {entry.thread_id or entry.path}") +@transcripts.command(name="warm") +@click.option("--session", "session_id", required=True, help="Claude Code session id to warm") +@click.option( + "--root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + required=True, + help="Project root whose hook tool registry matches the session", +) +@click.option("--max-seconds", type=click.IntRange(min=1, max=3600), default=900, show_default=True) +def transcripts_warm(session_id: str, root: Path, max_seconds: int) -> None: + """Warm a session's registered transcript facts under paced native work limits.""" + from contextlib import redirect_stderr, redirect_stdout + from io import StringIO + from time import monotonic, sleep, time + + from cc_transcript.discovery import CLAUDE_PROJECTS_DIR + + from captain_hook.session import state_root + from captain_hook.snapshots.client import ( + NATIVE_CLASSIFIER, + EvidenceIncomplete, + RegisteredWarmState, + RootWarmState, + client_scope, + ) + from captain_hook.transcripts import ( + INVALID_SESSION_ID, + configured_classifier_policy, + registered_sources, + resolved_transcript_paths, + ) + from captain_hook.worker.service import WARM_INTERVAL_SECONDS, WARM_STEP_BYTES, WARM_STEP_SECONDS + + if not session_id or INVALID_SESSION_ID.search(session_id): + raise click.UsageError("invalid session id") + session_dir = state_root() / "hooks" / "sessions" / SessionId(session_id) + sources = registered_sources(session_dir) + with redirect_stdout(StringIO()), redirect_stderr(StringIO()): + CliState(root=root.resolve()).discover() + if _state.load_errors: + raise click.ClickException("hook discovery failed for project root") + started = monotonic() + + def advance(state: RegisteredWarmState | RootWarmState) -> tuple[bool, int, str | None, int, int]: + steps = read_bytes = cache_hits = 0 + complete = False + failure = None + while (remaining := max_seconds - (monotonic() - started)) > 0: + steps += 1 + try: + complete = state.step(read_bytes=WARM_STEP_BYTES, deadline_seconds=min(WARM_STEP_SECONDS, remaining)) + except EvidenceIncomplete as exc: + read_bytes += state.last_usage.get("source_bytes_read", 0) + cache_hits += state.last_usage.get("cache_hits", 0) + failure = exc.status + break + read_bytes += state.last_usage["source_bytes_read"] + cache_hits += state.last_usage["cache_hits"] + if complete: + break + if state.last_usage["source_bytes_read"]: + sleep(min(WARM_INTERVAL_SECONDS, max(0, max_seconds - (monotonic() - started)))) + return complete, steps, failure, read_bytes, cache_hits + + with client_scope() as client: + client.foreground_deadline_unix_ms = int((time() + max_seconds) * 1000) + client.tool_registry() + try: + root_path = resolved_transcript_paths(client, [SessionId(session_id)], roots=[CLAUDE_PROJECTS_DIR])[ + SessionId(session_id) + ] + except EvidenceIncomplete as exc: + raise click.ClickException(f"root transcript location {exc.status}") from exc + if root_path is None: + raise click.ClickException("root transcript is unavailable") + root_warmer = RootWarmState(root_path, NATIVE_CLASSIFIER, client) + root_warmed, root_steps, root_failure, root_reads, _ = advance(root_warmer) + root_verified = False + root_verify_reads = 0 + classifier = NATIVE_CLASSIFIER + if root_warmed: + root_verified, _, verify_failure, root_verify_reads, _ = advance( + RootWarmState(root_path, NATIVE_CLASSIFIER, client) + ) + root_failure = root_failure or verify_failure + classifier_ready = False + if root_verified and _state.classifier is not None: + classifier = configured_classifier_policy(root) + classifier_ready = True + root_failure = "classifier_unavailable" + elif root_verified: + try: + session = client.acquire(root_path) + try: + prepared = list( + client.pages( + "prepare_hook_view", + domain=True, + view=session.view(), + cwd=str(root.resolve()), + droid=False, + ) + ) + if len(prepared) != 1 or prepared[0]["kind"] != "classifier": + raise click.ClickException("root classifier preparation returned invalid evidence") + classifier = prepared[0]["classifier"] + classifier_ready = True + finally: + session.release() + except EvidenceIncomplete as exc: + root_failure = root_failure or exc.status + classified_warmed = classified_verified = classifier_ready and classifier == NATIVE_CLASSIFIER + classified_reads = classified_verify_reads = 0 + classified_steps = 0 + if classifier_ready and classifier != NATIVE_CLASSIFIER and classifier["id"] != "captain-configured": + classified_warmed, classified_steps, classified_failure, classified_reads, _ = advance( + RootWarmState(root_path, classifier, client) + ) + root_failure = root_failure or classified_failure + if classified_warmed: + classified_verified, _, verify_failure, classified_verify_reads, _ = advance( + RootWarmState(root_path, classifier, client) + ) + root_failure = root_failure or verify_failure + warmer = RegisteredWarmState(sources, client) + warmed, steps, failure_status, warm_reads, _ = ( + advance(warmer) if sources.thread_ids or sources.direct_paths else (True, 0, None, 0, 0) + ) + verified = False + verify_steps = verify_reads = verify_hits = 0 + if warmed: + if sources.thread_ids or sources.direct_paths: + verified, verify_steps, verify_failure, verify_reads, verify_hits = advance( + RegisteredWarmState(sources, client) + ) + failure_status = failure_status or verify_failure + else: + verified = True + complete = root_warmed and root_verified and classified_warmed and classified_verified and warmed and verified + click.echo( + json.dumps( + { + "complete": complete, + "root_steps": root_steps + classified_steps, + "root_source_bytes_read": root_reads + classified_reads, + "root_verify_source_bytes_read": root_verify_reads + classified_verify_reads, + "root_facts_complete": classified_warmed and classified_verified, + "sources": len(sources.thread_ids) + len(sources.direct_paths), + "steps": steps, + "fact_cache_bytes": warmer.fact_cache_bytes, + "fact_cache_write_bytes": warmer.fact_cache_write_bytes, + "fact_cache_writes": warmer.fact_cache_writes, + "warm_source_bytes_read": warm_reads, + "verify_complete": verified, + "verify_steps": verify_steps, + "verify_source_bytes_read": verify_reads, + "verify_cache_hits": verify_hits, + "elapsed_seconds": round(monotonic() - started, 3), + "status": root_failure or failure_status or ("complete" if complete else "deadline"), + }, + sort_keys=True, + ) + ) + if not complete: + raise SystemExit(1) + + @cli.command() def mcp() -> None: """Serve the capt-hook MCP server over stdio, exposing the register_transcript tool.""" diff --git a/captain_hook/snapshots/client.py b/captain_hook/snapshots/client.py index 7f9579ee..5570ca90 100644 --- a/captain_hook/snapshots/client.py +++ b/captain_hook/snapshots/client.py @@ -11,7 +11,7 @@ import uuid from collections.abc import Callable, Collection, Iterator, Mapping, Sequence from contextlib import ExitStack, contextmanager -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -28,6 +28,10 @@ MAX_RESULT_BYTES = 16 * 1024 * 1024 MAX_VIEW_ATTACHMENTS = 1024 CLEANUP_SECONDS = 5 +GRAPH_WORK_SECONDS = 0.75 +GRAPH_READ_BYTES = 1024 * 1024 +GRAPH_DISCOVERY_ENTRIES = 50_000 +GRAPH_SOURCE_LIMIT = 4096 DEFAULT_LIMITS = { "max_read_bytes": 512 * 1024 * 1024, "max_events": 1_000_000, @@ -39,6 +43,14 @@ NATIVE_CLASSIFIER = {"id": "native", "version": "1"} +def graph_limits() -> dict[str, int]: + limits = DEFAULT_LIMITS.copy() + limits["max_read_bytes"] = min(limits["max_read_bytes"], GRAPH_READ_BYTES) + limits["max_discovery_entries"] = min(limits["max_discovery_entries"], GRAPH_DISCOVERY_ENTRIES) + limits["max_sources"] = min(limits["max_sources"], GRAPH_SOURCE_LIMIT) + return limits + + class EvidenceIncomplete(RuntimeError): def __init__(self, status: str, reason: str) -> None: super().__init__(f"transcript evidence {status}: {reason}") @@ -56,6 +68,10 @@ def __init__(self) -> None: super().__init__("source_limit", f"registered transcript attachments exceed {MAX_VIEW_ATTACHMENTS}") +class GraphEvidenceExpired(EvidenceIncomplete): + pass + + def encode_frame(message: Mapping[str, object]) -> bytes: raw = bytearray() encoder = json.JSONEncoder(ensure_ascii=False, separators=(",", ":"), allow_nan=False) @@ -153,11 +169,25 @@ def __init__( *, cleanup_exchange: Callable[[dict[str, object]], dict[str, Any]] | None = None, preparation_seconds: float = 120, + warm_scheduler: Callable[[SnapshotClient, GraphSources], None] | None = None, + root_warm_scheduler: Callable[[SnapshotClient, Path, Mapping[str, str]], None] | None = None, + defer_cleanup: bool = False, + foreground_seconds: float | None = None, + foreground_read_bytes: int | None = None, ) -> None: self._exchange = exchange self._cleanup_exchange = cleanup_exchange or exchange self._leases: set[Lease] = set() + self._graphs: set[PreparedGraphEvidence] = set() self._preparation_seconds = preparation_seconds + self._warm_scheduler = warm_scheduler + self._root_warm_scheduler = root_warm_scheduler + self._defer_cleanup = defer_cleanup + self._deferred_cursors: set[tuple[str, str | None]] = set() + self._foreground_seconds = foreground_seconds + self._foreground_read_bytes = foreground_read_bytes + self._foreground_source_bytes = 0 + self.foreground_deadline_unix_ms: int | None = None self._prefix = uuid.uuid4().hex self._counter = 0 self._guard = threading.Lock() @@ -201,6 +231,19 @@ def tool_registry(self) -> list[dict[str, object]]: for name, kind, fields in self._tool_registry ] + def clone_for_exchange(self, exchange: Callable[[dict[str, object]], dict[str, Any]]) -> SnapshotClient: + clone = SnapshotClient(exchange) + clone._tool_registry = self._tool_registry + return clone + + def schedule_graph_warm(self, sources: GraphSources) -> None: + if self._warm_scheduler is not None: + self._warm_scheduler(self, sources) + + def schedule_root_warm(self, path: str | Path, classifier: Mapping[str, str]) -> None: + if classifier["id"] != "captain-configured" and self._root_warm_scheduler is not None: + self._root_warm_scheduler(self, Path(path).absolute(), classifier) + def call(self, operation: str, *, domain: bool = False, **arguments: object) -> dict[str, Any]: from jsonschema import ValidationError @@ -212,6 +255,14 @@ def call(self, operation: str, *, domain: bool = False, **arguments: object) -> with self._guard: self._counter += 1 request_id = f"{self._prefix}-{self._counter}" + if self._foreground_seconds is not None and self.foreground_deadline_unix_ms is None: + self.foreground_deadline_unix_ms = int((time.time() + self._foreground_seconds) * 1000) + if ( + self.foreground_deadline_unix_ms is not None + and operation != "release" + and int(time.time() * 1000) >= self.foreground_deadline_unix_ms + ): + raise EvidenceIncomplete("deadline", "foreground transcript deadline exhausted") request: dict[str, object] = { "schema": HOST_SCHEMA if domain else CORE_SCHEMA, "id": request_id, @@ -219,8 +270,20 @@ def call(self, operation: str, *, domain: bool = False, **arguments: object) -> **arguments, } if operation not in {"resume", "release", "retain", "renew", "describe", "stats", "submit_classifier"}: - request["deadline_unix_ms"] = int((time.time() + self._preparation_seconds) * 1000) - request["limits"] = DEFAULT_LIMITS.copy() + request.setdefault("deadline_unix_ms", int((time.time() + self._preparation_seconds) * 1000)) + request.setdefault("limits", DEFAULT_LIMITS.copy()) + if self.foreground_deadline_unix_ms is not None and "deadline_unix_ms" in request: + request["deadline_unix_ms"] = min(int(request["deadline_unix_ms"]), self.foreground_deadline_unix_ms) + remaining_bytes = None + if self._foreground_read_bytes is not None and operation != "release": + with self._guard: + remaining_bytes = self._foreground_read_bytes - self._foreground_source_bytes + if remaining_bytes <= 0: + raise EvidenceIncomplete("incomplete", "foreground transcript byte budget exhausted") + if remaining_bytes is not None and "limits" in request: + limits = dict(request["limits"]) + limits["max_read_bytes"] = min(int(limits["max_read_bytes"]), remaining_bytes) + request["limits"] = limits exchange = self._cleanup_exchange if operation == "release" else self._exchange try: response = exchange({"schema": HOST_SCHEMA, "request": request, "tool_registry": self.tool_registry()}) @@ -232,6 +295,11 @@ def call(self, operation: str, *, domain: bool = False, **arguments: object) -> result = response["response"] if result.get("schema") != CORE_SCHEMA or result.get("id") != request_id: raise SnapshotProtocolError("snapshot response schema or id mismatch") + if self._foreground_read_bytes is not None and operation != "release": + with self._guard: + self._foreground_source_bytes += result["usage"]["source_bytes_read"] + if self._foreground_source_bytes > self._foreground_read_bytes: + raise SnapshotProtocolError("foreground transcript byte budget was exceeded") if cleanup_deadline is None or result.get("status") != "retained_limit": return result remaining = cleanup_deadline - time.monotonic() @@ -266,7 +334,10 @@ def pages(self, operation: str, *, domain: bool = False, **arguments: object) -> cursor = result.get("cursor") finally: if cursor is not None: - self.call("release", kind="cursor", token=cursor) + if self._defer_cleanup: + self._deferred_cursors.add((cursor, None)) + else: + self.call("release", kind="cursor", token=cursor) def acquire(self, path: str | Path, *, classifier: Mapping[str, str] = NATIVE_CLASSIFIER) -> RemoteSession: description = None @@ -309,7 +380,11 @@ def classify( cursor = result.get("cursor") finally: if cursor is not None: - self.call("release", owner_epoch=session.lease.handle["owner_epoch"], kind="cursor", token=cursor) + owner_epoch = session.lease.handle["owner_epoch"] + if self._defer_cleanup: + self._deferred_cursors.add((cursor, owner_epoch)) + else: + self.call("release", owner_epoch=owner_epoch, kind="cursor", token=cursor) data = result["data"] if result["status"] != "ok" or data["kind"] != "acquired": raise SnapshotProtocolError("classifier completed without a leased description") @@ -318,7 +393,37 @@ def classify( session.release() return classified + def close_pending(self) -> None: + self._defer_cleanup = False + errors = [] + for cursor, owner_epoch in tuple(self._deferred_cursors): + try: + arguments = {"kind": "cursor", "token": cursor} + if owner_epoch is not None: + arguments["owner_epoch"] = owner_epoch + result = self.call("release", **arguments) + if result.get("status") not in {"ok", "stale_handle"}: + raise EvidenceIncomplete(str(result.get("status")), str(result.get("reason"))) + self._deferred_cursors.discard((cursor, owner_epoch)) + except Exception as exc: + errors.append(exc) + for graph in tuple(self._graphs): + try: + graph.retry_release() + except Exception as exc: + errors.append(exc) + for lease in tuple(self._leases): + if not lease.cleanup_pending: + continue + try: + lease.release() + except Exception as exc: + errors.append(exc) + if errors: + raise ExceptionGroup("snapshot cleanup failed", errors) + def close(self) -> None: + self.close_pending() errors = [] for lease in tuple(self._leases): try: @@ -337,16 +442,19 @@ def __init__(self, client: SnapshotClient, description: Mapping[str, Any]) -> No self.handle = dict(description["handle"]) self.expires_unix_ms = description["lease_expires_unix_ms"] self.released = False + self.closed = False + self.cleanup_pending = False + self.graph_released = False self.guard = threading.Lock() self.subagent_guard = threading.Lock() self.subagent_views: dict[str, RemoteSubagentIndex] = {} - def require(self) -> dict[str, str]: + def require(self, client: SnapshotClient | None = None) -> dict[str, str]: with self.guard: - if self.released: + if self.closed or self.released: raise EvidenceIncomplete("stale_handle", "preparation lease was already released") if self.expires_unix_ms <= time.time() * 1000 + 1000: - result = self.client.call("renew", handle=self.handle) + result = (client or self.client).call("renew", handle=self.handle) if result["status"] != "ok": raise EvidenceIncomplete(result["status"], result["reason"]) self.expires_unix_ms = result["data"]["expires_unix_ms"] @@ -365,6 +473,10 @@ def _release(self) -> None: with self.guard: if self.released: return + self.closed = True + if self.client._defer_cleanup: + self.cleanup_pending = True + return result = self.client.call( "release", owner_epoch=self.handle["owner_epoch"], kind="lease", token=self.handle["lease_id"] ) @@ -372,14 +484,300 @@ def _release(self) -> None: logger.bind(status="retained_limit", reason=result.get("reason")).warning( "snapshot lease cleanup deferred" ) - self.client._leases.discard(self) + self.cleanup_pending = True return if result.get("status") not in {"ok", "stale_handle"}: raise EvidenceIncomplete(str(result.get("status")), str(result.get("reason"))) self.released = True + self.cleanup_pending = False self.client._leases.discard(self) +@dataclass(frozen=True) +class GraphSources: + thread_ids: tuple[str, ...] = () + roots: tuple[Path, ...] = () + direct_paths: tuple[Path, ...] = () + session_key: str | None = None + + +@dataclass +class RegisteredWarmState: + sources: GraphSources + client: SnapshotClient + start_index: int = 0 + owner_epoch: str | None = None + membership_revision: str | None = None + fact_cache_bytes: int = 0 + fact_cache_write_bytes: int = 0 + fact_cache_writes: int = 0 + source_offset: int = 0 + source_size: int = 0 + stalled_steps: int = 0 + last_usage: dict[str, int] = field(default_factory=dict) + + def step(self, *, read_bytes: int, deadline_seconds: float) -> bool: + limits = graph_limits() | {"max_read_bytes": read_bytes} + result = self.client.call( + "warm_registered", + classifier=NATIVE_CLASSIFIER, + thread_ids=list(self.sources.thread_ids), + roots=[str(root) for root in self.sources.roots], + direct_paths=[str(path) for path in self.sources.direct_paths], + start_index=self.start_index, + membership_revision=self.membership_revision, + deadline_unix_ms=int((time.time() + deadline_seconds) * 1000), + limits=limits, + ) + usage = result["usage"] + self.last_usage = dict(usage) + if result["status"] == "changed": + self.start_index = 0 + self.membership_revision = None + self.source_offset = 0 + self.source_size = 0 + self.stalled_steps = 0 + return False + if result["status"] in {"incomplete", "deadline", "retained_limit", "lease_limit", "cancelled"}: + self.stalled_steps += 1 + if self.stalled_steps >= 2: + raise EvidenceIncomplete("incomplete", "registered transcript warming made no progress") + return False + if result["status"] != "ok": + raise EvidenceIncomplete(str(result["status"]), str(result.get("reason"))) + data = result["data"] + if not isinstance(data, dict) or data.get("kind") != "warmed_registry": + raise SnapshotProtocolError("registered transcript warming returned invalid progress") + changed = (self.owner_epoch is not None and data["owner_epoch"] != self.owner_epoch) or ( + self.membership_revision is not None and data["membership_revision"] != self.membership_revision + ) + progress = ( + changed + or data["next_index"] > self.start_index + or data["source_offset"] > self.source_offset + or usage.get("source_bytes_read", 0) > 0 + or usage.get("events_parsed", 0) > 0 + ) + self.stalled_steps = 0 if progress else self.stalled_steps + 1 + self.start_index = 0 if changed else data["next_index"] + self.owner_epoch = data["owner_epoch"] + self.membership_revision = data["membership_revision"] + self.fact_cache_bytes = data["fact_cache_bytes"] + self.fact_cache_write_bytes = data["fact_cache_write_bytes"] + self.fact_cache_writes = data["fact_cache_writes"] + self.source_offset = 0 if changed else data["source_offset"] + self.source_size = 0 if changed else data["source_size"] + if self.stalled_steps >= 2 and not data["complete"]: + raise EvidenceIncomplete("incomplete", "registered transcript warming made no progress") + return data["complete"] and not changed + + +@dataclass +class RootWarmState: + path: Path + classifier: Mapping[str, str] + client: SnapshotClient + owner_epoch: str | None = None + source_revision: str | None = None + source_offset: int = 0 + source_size: int = 0 + facts_complete: bool = False + stalled_steps: int = 0 + last_usage: dict[str, int] = field(default_factory=dict) + + def step(self, *, read_bytes: int, deadline_seconds: float) -> bool: + result = self.client.call( + "warm_root", + path=str(self.path), + classifier=dict(self.classifier), + deadline_unix_ms=int((time.time() + deadline_seconds) * 1000), + limits=graph_limits() | {"max_read_bytes": read_bytes}, + ) + self.last_usage = dict(result["usage"]) + if result["status"] in {"incomplete", "deadline", "retained_limit", "lease_limit", "cancelled"}: + self.stalled_steps += 1 + if self.stalled_steps >= 2: + raise EvidenceIncomplete("incomplete", "root transcript warming made no progress") + return False + if result["status"] != "ok": + raise EvidenceIncomplete(str(result["status"]), str(result.get("reason"))) + data = result["data"] + if not isinstance(data, dict) or data.get("kind") != "warmed_root": + raise SnapshotProtocolError("root transcript warming returned invalid progress") + changed = (self.owner_epoch is not None and data["owner_epoch"] != self.owner_epoch) or ( + self.source_revision is not None and data["source_revision"] != self.source_revision + ) + progress = ( + changed + or data["source_offset"] > self.source_offset + or (data["facts_complete"] and not self.facts_complete) + or self.last_usage.get("source_bytes_read", 0) > 0 + or self.last_usage.get("events_parsed", 0) > 0 + ) + self.stalled_steps = 0 if progress else self.stalled_steps + 1 + self.owner_epoch = data["owner_epoch"] + self.source_revision = data["source_revision"] + self.source_offset = data["source_offset"] + self.source_size = data["source_size"] + self.facts_complete = data["facts_complete"] + complete = data["complete"] and data["facts_complete"] + if self.stalled_steps >= 2 and not complete: + raise EvidenceIncomplete("incomplete", "root transcript warming made no progress") + return complete + + +@dataclass(frozen=True, slots=True) +class PreparedGraphHandle: + graph_id: str + owner_epoch: str + revision: str + complete: Literal[True] = True + + @classmethod + def from_wire(cls, value: Mapping[str, object]) -> PreparedGraphHandle: + if value.get("complete") is not True or not all( + isinstance(value.get(name), str) and value[name] for name in ("graph_id", "owner_epoch", "revision") + ): + raise SnapshotProtocolError("graph preparation returned an incomplete handle") + return cls(value["graph_id"], value["owner_epoch"], value["revision"]) + + def wire(self) -> dict[str, object]: + return { + "graph_id": self.graph_id, + "owner_epoch": self.owner_epoch, + "revision": self.revision, + "complete": self.complete, + } + + +class PreparedGraphEvidence: + def __init__(self, sources: GraphSources) -> None: + self.sources = sources + self.guard = threading.RLock() + self.references = 1 + self.outcome: PreparedGraphHandle | EvidenceIncomplete | None = None + self.classifier: tuple[tuple[str, str], ...] | None = None + self.client: SnapshotClient | None = None + self.native_released = False + self.deadline_unix_ms: int | None = None + + def require_unprepared_classifier_change(self) -> None: + with self.guard: + if self.outcome is not None or self.references != 1: + raise EvidenceIncomplete("changed", "classifier changed after graph preparation or sharing") + + def retain(self) -> PreparedGraphEvidence: + with self.guard: + if self.references == 0: + raise EvidenceIncomplete("stale_handle", "prepared graph is closed") + self.references += 1 + return self + + def require(self, session: RemoteSession) -> PreparedGraphHandle: + with self.guard: + if self.references == 0: + raise EvidenceIncomplete("stale_handle", "prepared graph is closed") + classifier = tuple(sorted(session.classifier.items())) + if self.classifier is not None and self.classifier != classifier: + raise EvidenceIncomplete("changed", "prepared graph classifier does not match this branch") + if isinstance(self.outcome, EvidenceIncomplete): + raise self.outcome + if self.outcome is None: + self.classifier = classifier + try: + from captain_hook.util import reqenv + + deadline = int((time.time() + GRAPH_WORK_SECONDS) * 1000) + if session.client.foreground_deadline_unix_ms is not None: + deadline = min(deadline, session.client.foreground_deadline_unix_ms) + if (request := reqenv.current()) is not None and request.deadline_unix_ms: + deadline = min(deadline, request.deadline_unix_ms - 1000) + if deadline <= int(time.time() * 1000): + raise EvidenceIncomplete("deadline", "no time remains for prepared graph evidence") + self.deadline_unix_ms = deadline + if ( + len(self.sources.thread_ids) > 1024 + or len(self.sources.direct_paths) > 1024 + or len(self.sources.roots) > 64 + ): + raise AttachmentLimit() + pages = list( + session.client.pages( + "prepare_graph", + view=session.view() | {"selectors": []}, + thread_ids=list(self.sources.thread_ids), + roots=[str(path) for path in self.sources.roots], + direct_paths=[str(path) for path in self.sources.direct_paths], + deadline_unix_ms=deadline, + limits=graph_limits(), + ) + ) + if len(pages) != 1 or pages[0].get("kind") != "prepared_graph": + raise SnapshotProtocolError("graph preparation returned an unexpected result") + self.outcome = PreparedGraphHandle.from_wire(pages[0]["handle"]) + self.client = session.client + self.client._graphs.add(self) + except EvidenceIncomplete as exc: + failure = ( + GraphEvidenceExpired(exc.status, exc.reason) + if exc.status in {"stale_handle", "stale_cursor"} + else exc + ) + self.outcome = failure + if failure is exc: + raise + raise failure from exc + return self.outcome + + def query_pages(self, session: RemoteSession, query: Mapping[str, object]) -> Iterator[dict[str, Any]]: + try: + handle = self.require(session) + assert self.deadline_unix_ms is not None + yield from session.client.pages( + "query_graph", + handle=handle.wire(), + selectors=[dict(selector) for selector in session.selectors], + query=dict(query), + deadline_unix_ms=self.deadline_unix_ms, + limits=graph_limits(), + ) + except EvidenceIncomplete as exc: + if exc.status in {"incomplete", "deadline"}: + session.client.schedule_graph_warm(self.sources) + session.client.schedule_root_warm(session.path, session.classifier) + if exc.status in {"stale_handle", "stale_cursor"}: + raise GraphEvidenceExpired(exc.status, exc.reason) from exc + raise + + def release(self) -> None: + with self.guard: + if self.references == 0: + return + self.references -= 1 + self.retry_release() + + def retry_release(self) -> None: + with self.guard: + if self.references != 0 or self.native_released or not isinstance(self.outcome, PreparedGraphHandle): + return + client = self.client + assert client is not None + if client._defer_cleanup: + return + result = client.call( + "release", kind="graph", token=self.outcome.graph_id, owner_epoch=self.outcome.owner_epoch + ) + if result.get("status") == "retained_limit": + logger.bind(status="retained_limit", reason=result.get("reason")).warning( + "prepared graph cleanup deferred" + ) + return + if result.get("status") not in {"ok", "stale_handle"}: + raise EvidenceIncomplete(str(result.get("status")), str(result.get("reason"))) + self.native_released = True + client._graphs.discard(self) + + @dataclass(frozen=True) class RemoteSession: client: SnapshotClient @@ -387,19 +785,19 @@ class RemoteSession: path: Path classifier: Mapping[str, str] selectors: tuple[Mapping[str, object], ...] = () - attachments: tuple[Path, ...] = () + graph: PreparedGraphEvidence = field(default_factory=lambda: PreparedGraphEvidence(GraphSources())) - def view(self, *, deep: bool = False) -> dict[str, object]: - attachments = self.attachments if deep else () - if len(attachments) > MAX_VIEW_ATTACHMENTS: - raise AttachmentLimit() + def view(self) -> dict[str, object]: return { "handle": self.lease.require(), "classifier": dict(self.classifier), "selectors": [dict(s) for s in self.selectors], - "attachments": [str(p) for p in attachments], + "attachments": [], } + def with_registered_sources(self, sources: GraphSources) -> RemoteSession: + return replace(self, graph=PreparedGraphEvidence(sources)) + def selected(self, **selector: object) -> RemoteSession: return replace(self, selectors=(*self.selectors, selector)) @@ -423,37 +821,65 @@ def before(self, *, tool: str) -> RemoteSession: return self.selected(kind="before_last_tool", name=tool, file=None) def retain(self) -> RemoteSession: - data = list(self.client.pages("retain", handle=self.lease.require())) + client = CURRENT_CLIENT.get() or self.client + data = list(client.pages("retain", handle=self.lease.require(client))) if len(data) != 1 or data[0].get("kind") != "acquired": raise SnapshotProtocolError("retain completed without one leased description") - return replace(self, lease=Lease(self.client, data[0]["description"])) + lease = Lease(client, data[0]["description"]) + try: + graph = self.graph.retain() + except BaseException: + lease.release() + raise + return replace(self, client=client, lease=lease, graph=graph) def with_classifier(self, classifier: Mapping[str, str]) -> RemoteSession: if classifier == self.classifier: return self - self.lease.require() - classified = self.client.acquire(self.path, classifier=classifier) - try: - if any( - self.lease.description[field] != classified.lease.description[field] - for field in ( - "source_id", - "mtime_ns", - "ctime_ns", - "source_bytes", - "committed_bytes", - "provisional_tail", - ) - ): - raise EvidenceIncomplete("changed", "transcript changed during classifier selection") - except BaseException: - classified.release() - raise - self.release() - return replace(classified, selectors=self.selectors, attachments=self.attachments) + with self.graph.guard: + self.graph.require_unprepared_classifier_change() + self.lease.require() + client = CURRENT_CLIENT.get() or self.client + try: + classified = client.acquire(self.path, classifier=classifier) + except EvidenceIncomplete as exc: + if exc.status in {"incomplete", "deadline"}: + client.schedule_root_warm(self.path, classifier) + raise + try: + if any( + self.lease.description[field] != classified.lease.description[field] + for field in ( + "source_id", + "mtime_ns", + "ctime_ns", + "source_bytes", + "committed_bytes", + "provisional_tail", + ) + ): + raise EvidenceIncomplete("changed", "transcript changed during classifier selection") + except BaseException: + classified.release() + raise + graph = self.graph.retain() + try: + self.release() + except BaseException: + graph.release() + classified.release() + raise + classified.graph.release() + return replace(classified, selectors=self.selectors, graph=graph) def release(self) -> None: - self.lease.release() + with self.lease.guard: + release_graph = not self.lease.graph_released + self.lease.graph_released = True + with ExitStack() as cleanup: + cleanup.callback(self.lease.release) + if release_graph: + cleanup.callback(self.graph.release) @property def evidence_ref(self) -> str: @@ -466,7 +892,14 @@ def query(self, query: Mapping[str, object]) -> Any: "deep_predicate_inputs", "sidechain_membership", } - for data in self.client.pages("query", view=self.view(deep=deep), query=dict(query)): + if deep and query["kind"] == "sidechain_membership": + raise EvidenceIncomplete("invalid_request", "sidechain membership has no prepared graph projection") + pages = ( + self.graph.query_pages(self, query) + if deep + else self.client.pages("query", view=self.view(), query=dict(query)) + ) + for data in pages: match data.get("kind"): case "scalar": values.append(data["value"]) diff --git a/captain_hook/snapshots/schemas/config.schema.json b/captain_hook/snapshots/schemas/config.schema.json index e1bdfb22..3e1003a7 100644 --- a/captain_hook/snapshots/schemas/config.schema.json +++ b/captain_hook/snapshots/schemas/config.schema.json @@ -51,6 +51,20 @@ "title": "Max Preparation Ms", "type": "integer" }, + "max_prepared_disk_bytes": { + "default": 2147483648, + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Max Prepared Disk Bytes", + "type": "integer" + }, + "max_prepared_fact_memory_bytes": { + "default": 134217728, + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Max Prepared Fact Memory Bytes", + "type": "integer" + }, "max_projection_bytes": { "default": 16777216, "exclusiveMinimum": 0, diff --git a/captain_hook/snapshots/schemas/context.schema.json b/captain_hook/snapshots/schemas/context.schema.json index 5fe9df5b..17f6b94e 100644 --- a/captain_hook/snapshots/schemas/context.schema.json +++ b/captain_hook/snapshots/schemas/context.schema.json @@ -96,6 +96,15 @@ "minLength": 1, "title": "Registry Generation", "type": "string" + }, + "work_class": { + "default": "foreground", + "enum": [ + "foreground", + "background" + ], + "title": "Work Class", + "type": "string" } }, "required": [ diff --git a/captain_hook/snapshots/schemas/host-request.schema.json b/captain_hook/snapshots/schemas/host-request.schema.json index 09051571..2040ba87 100644 --- a/captain_hook/snapshots/schemas/host-request.schema.json +++ b/captain_hook/snapshots/schemas/host-request.schema.json @@ -378,13 +378,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -392,6 +395,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1036,6 +1048,42 @@ "title": "Gauges", "type": "object" }, + "Core_GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Core_Handle": { "additionalProperties": false, "properties": { @@ -1251,13 +1299,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -1265,6 +1316,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1811,6 +1871,320 @@ "title": "PendingNamedTask", "type": "object" }, + "Core_PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/Core_PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "Core_PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "Core_PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, + "Core_PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "Core_PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "Core_PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "Core_PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "Core_PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "handle": { + "$ref": "#/$defs/Core_Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" + ], + "title": "PreparedView", + "type": "object" + }, "Core_Prior": { "additionalProperties": false, "properties": { @@ -1851,11 +2225,129 @@ } }, "required": [ - "kind", - "selection", - "count" + "kind", + "selection", + "count" + ], + "title": "Prompts", + "type": "object" + }, + "Core_QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/Core_PreparedInputs", + "has_command": "#/$defs/Core_PreparedListPredicate", + "has_command_regex": "#/$defs/Core_PreparedToolPredicate", + "has_edit": "#/$defs/Core_PreparedErrorPredicate", + "has_edit_to": "#/$defs/Core_PreparedListPredicate", + "has_error": "#/$defs/Core_PreparedErrorPredicate", + "has_override": "#/$defs/Core_PreparedOverridePredicate", + "has_read": "#/$defs/Core_PreparedToolPredicate", + "has_read_glob": "#/$defs/Core_PreparedListPredicate", + "has_skill": "#/$defs/Core_PreparedListPredicate", + "has_skill_suffix": "#/$defs/Core_PreparedListPredicate", + "has_tool": "#/$defs/Core_PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_PreparedInputs" + }, + { + "$ref": "#/$defs/Core_PreparedToolPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedListPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/Core_PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" ], - "title": "Prompts", + "title": "QueryGraph", "type": "object" }, "Core_QueryRequest": { @@ -1983,7 +2475,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/Core_View" + "$ref": "#/$defs/Core_PreparedView" } }, "required": [ @@ -2069,7 +2561,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -2085,7 +2578,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -3272,6 +3766,285 @@ "title": "WaitingResult", "type": "object" }, + "Core_WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "Core_WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, + "Core_WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "Core_WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" + }, "Core_WorkflowText": { "additionalProperties": false, "properties": { @@ -3378,13 +4151,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3392,6 +4168,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -3533,13 +4318,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3547,6 +4335,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -4141,13 +4938,17 @@ "hydrate": "#/$defs/Core_Hydrate", "locate": "#/$defs/Core_Locate", "mine": "#/$defs/Core_Mine", + "prepare_graph": "#/$defs/Core_PrepareGraph", "query": "#/$defs/Core_QueryRequest", + "query_graph": "#/$defs/Core_QueryGraph", "release": "#/$defs/Core_Release", "renew": "#/$defs/Core_Renew", "resolve": "#/$defs/Core_Resolve", "resume": "#/$defs/Core_Resume", "retain": "#/$defs/Core_Retain", - "stats": "#/$defs/Core_Stats" + "stats": "#/$defs/Core_Stats", + "warm_registered": "#/$defs/Core_WarmRegistered", + "warm_root": "#/$defs/Core_WarmRoot" }, "propertyName": "operation" }, @@ -4191,6 +4992,18 @@ { "$ref": "#/$defs/Core_QueryRequest" }, + { + "$ref": "#/$defs/Core_PrepareGraph" + }, + { + "$ref": "#/$defs/Core_QueryGraph" + }, + { + "$ref": "#/$defs/Core_WarmRegistered" + }, + { + "$ref": "#/$defs/Core_WarmRoot" + }, { "$ref": "#/$defs/Core_ActivityProbe" }, diff --git a/captain_hook/snapshots/schemas/host-response.schema.json b/captain_hook/snapshots/schemas/host-response.schema.json index 6f71fedf..6fdd5f39 100644 --- a/captain_hook/snapshots/schemas/host-response.schema.json +++ b/captain_hook/snapshots/schemas/host-response.schema.json @@ -378,13 +378,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -392,6 +395,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1036,6 +1048,42 @@ "title": "Gauges", "type": "object" }, + "Core_GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Core_Handle": { "additionalProperties": false, "properties": { @@ -1251,13 +1299,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -1265,6 +1316,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1811,6 +1871,320 @@ "title": "PendingNamedTask", "type": "object" }, + "Core_PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/Core_PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "Core_PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "Core_PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, + "Core_PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "Core_PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "Core_PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "Core_PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "Core_PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "handle": { + "$ref": "#/$defs/Core_Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" + ], + "title": "PreparedView", + "type": "object" + }, "Core_Prior": { "additionalProperties": false, "properties": { @@ -1851,11 +2225,129 @@ } }, "required": [ - "kind", - "selection", - "count" + "kind", + "selection", + "count" + ], + "title": "Prompts", + "type": "object" + }, + "Core_QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/Core_PreparedInputs", + "has_command": "#/$defs/Core_PreparedListPredicate", + "has_command_regex": "#/$defs/Core_PreparedToolPredicate", + "has_edit": "#/$defs/Core_PreparedErrorPredicate", + "has_edit_to": "#/$defs/Core_PreparedListPredicate", + "has_error": "#/$defs/Core_PreparedErrorPredicate", + "has_override": "#/$defs/Core_PreparedOverridePredicate", + "has_read": "#/$defs/Core_PreparedToolPredicate", + "has_read_glob": "#/$defs/Core_PreparedListPredicate", + "has_skill": "#/$defs/Core_PreparedListPredicate", + "has_skill_suffix": "#/$defs/Core_PreparedListPredicate", + "has_tool": "#/$defs/Core_PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_PreparedInputs" + }, + { + "$ref": "#/$defs/Core_PreparedToolPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedListPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/Core_PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" ], - "title": "Prompts", + "title": "QueryGraph", "type": "object" }, "Core_QueryRequest": { @@ -1983,7 +2475,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/Core_View" + "$ref": "#/$defs/Core_PreparedView" } }, "required": [ @@ -2069,7 +2561,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -2085,7 +2578,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -3272,6 +3766,285 @@ "title": "WaitingResult", "type": "object" }, + "Core_WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "Core_WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, + "Core_WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "Core_WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" + }, "Core_WorkflowText": { "additionalProperties": false, "properties": { @@ -3378,13 +4151,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3392,6 +4168,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -3533,13 +4318,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3547,6 +4335,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, diff --git a/captain_hook/snapshots/schemas/request.schema.json b/captain_hook/snapshots/schemas/request.schema.json index 2261864a..8bad21f0 100644 --- a/captain_hook/snapshots/schemas/request.schema.json +++ b/captain_hook/snapshots/schemas/request.schema.json @@ -489,6 +489,42 @@ "title": "EventRef", "type": "object" }, + "GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Handle": { "additionalProperties": false, "properties": { @@ -967,9 +1003,304 @@ } }, "required": [ - "kind" + "kind" + ], + "title": "PendingNamedTask", + "type": "object" + }, + "PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "handle": { + "$ref": "#/$defs/Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/ToolBoundary", + "before_last_tool": "#/$defs/ToolBoundary", + "current_turn": "#/$defs/CurrentTurn", + "event_range": "#/$defs/EventRange", + "prior": "#/$defs/Prior", + "recent_events": "#/$defs/Recent", + "recent_messages": "#/$defs/Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CurrentTurn" + }, + { + "$ref": "#/$defs/Prior" + }, + { + "$ref": "#/$defs/Recent" + }, + { + "$ref": "#/$defs/ToolBoundary" + }, + { + "$ref": "#/$defs/EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" ], - "title": "PendingNamedTask", + "title": "PreparedView", "type": "object" }, "Prior": { @@ -1019,6 +1350,124 @@ "title": "Prompts", "type": "object" }, + "QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/PreparedInputs", + "has_command": "#/$defs/PreparedListPredicate", + "has_command_regex": "#/$defs/PreparedToolPredicate", + "has_edit": "#/$defs/PreparedErrorPredicate", + "has_edit_to": "#/$defs/PreparedListPredicate", + "has_error": "#/$defs/PreparedErrorPredicate", + "has_override": "#/$defs/PreparedOverridePredicate", + "has_read": "#/$defs/PreparedToolPredicate", + "has_read_glob": "#/$defs/PreparedListPredicate", + "has_skill": "#/$defs/PreparedListPredicate", + "has_skill_suffix": "#/$defs/PreparedListPredicate", + "has_tool": "#/$defs/PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/PreparedInputs" + }, + { + "$ref": "#/$defs/PreparedToolPredicate" + }, + { + "$ref": "#/$defs/PreparedListPredicate" + }, + { + "$ref": "#/$defs/PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/ToolBoundary", + "before_last_tool": "#/$defs/ToolBoundary", + "current_turn": "#/$defs/CurrentTurn", + "event_range": "#/$defs/EventRange", + "prior": "#/$defs/Prior", + "recent_events": "#/$defs/Recent", + "recent_messages": "#/$defs/Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CurrentTurn" + }, + { + "$ref": "#/$defs/Prior" + }, + { + "$ref": "#/$defs/Recent" + }, + { + "$ref": "#/$defs/ToolBoundary" + }, + { + "$ref": "#/$defs/EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" + ], + "title": "QueryGraph", + "type": "object" + }, "QueryRequest": { "additionalProperties": false, "properties": { @@ -1144,7 +1593,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/View" + "$ref": "#/$defs/PreparedView" } }, "required": [ @@ -1191,7 +1640,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -1207,7 +1657,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -1807,6 +2258,156 @@ "title": "View", "type": "object" }, + "WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, "WorkflowText": { "additionalProperties": false, "properties": { @@ -1849,13 +2450,17 @@ "hydrate": "#/$defs/Hydrate", "locate": "#/$defs/Locate", "mine": "#/$defs/Mine", + "prepare_graph": "#/$defs/PrepareGraph", "query": "#/$defs/QueryRequest", + "query_graph": "#/$defs/QueryGraph", "release": "#/$defs/Release", "renew": "#/$defs/Renew", "resolve": "#/$defs/Resolve", "resume": "#/$defs/Resume", "retain": "#/$defs/Retain", - "stats": "#/$defs/Stats" + "stats": "#/$defs/Stats", + "warm_registered": "#/$defs/WarmRegistered", + "warm_root": "#/$defs/WarmRoot" }, "propertyName": "operation" }, @@ -1899,6 +2504,18 @@ { "$ref": "#/$defs/QueryRequest" }, + { + "$ref": "#/$defs/PrepareGraph" + }, + { + "$ref": "#/$defs/QueryGraph" + }, + { + "$ref": "#/$defs/WarmRegistered" + }, + { + "$ref": "#/$defs/WarmRoot" + }, { "$ref": "#/$defs/ActivityProbe" }, diff --git a/captain_hook/snapshots/schemas/response.schema.json b/captain_hook/snapshots/schemas/response.schema.json index a247c78a..d88687ff 100644 --- a/captain_hook/snapshots/schemas/response.schema.json +++ b/captain_hook/snapshots/schemas/response.schema.json @@ -89,13 +89,16 @@ "hydrated": "#/$defs/Hydrated", "loading": "#/$defs/Loading", "located": "#/$defs/Located", + "prepared_graph": "#/$defs/PreparedGraphResult", "records": "#/$defs/RecordsResult", "released": "#/$defs/Released", "renewed": "#/$defs/Renewed", "resolved": "#/$defs/Resolved", "scalar": "#/$defs/ScalarResult", "stats": "#/$defs/StatsResult", - "strings": "#/$defs/StringsResult" + "strings": "#/$defs/StringsResult", + "warmed_registry": "#/$defs/WarmedRegistryResult", + "warmed_root": "#/$defs/WarmedRootResult" }, "propertyName": "kind" }, @@ -103,6 +106,15 @@ { "$ref": "#/$defs/Acquired" }, + { + "$ref": "#/$defs/PreparedGraphResult" + }, + { + "$ref": "#/$defs/WarmedRegistryResult" + }, + { + "$ref": "#/$defs/WarmedRootResult" + }, { "$ref": "#/$defs/Loading" }, @@ -513,6 +525,42 @@ "title": "Gauges", "type": "object" }, + "GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Handle": { "additionalProperties": false, "properties": { @@ -645,13 +693,16 @@ "hydrated": "#/$defs/Hydrated", "loading": "#/$defs/Loading", "located": "#/$defs/Located", + "prepared_graph": "#/$defs/PreparedGraphResult", "records": "#/$defs/RecordsResult", "released": "#/$defs/Released", "renewed": "#/$defs/Renewed", "resolved": "#/$defs/Resolved", "scalar": "#/$defs/ScalarResult", "stats": "#/$defs/StatsResult", - "strings": "#/$defs/StringsResult" + "strings": "#/$defs/StringsResult", + "warmed_registry": "#/$defs/WarmedRegistryResult", + "warmed_root": "#/$defs/WarmedRootResult" }, "propertyName": "kind" }, @@ -659,6 +710,15 @@ { "$ref": "#/$defs/Acquired" }, + { + "$ref": "#/$defs/PreparedGraphResult" + }, + { + "$ref": "#/$defs/WarmedRegistryResult" + }, + { + "$ref": "#/$defs/WarmedRootResult" + }, { "$ref": "#/$defs/Loading" }, @@ -842,6 +902,25 @@ "title": "Location", "type": "object" }, + "PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, "RecordsResult": { "additionalProperties": false, "properties": { @@ -1341,6 +1420,135 @@ ], "title": "WaitingResult", "type": "object" + }, + "WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/captain_hook/snapshots/worker.py b/captain_hook/snapshots/worker.py index 03a6a212..538fdf8a 100644 --- a/captain_hook/snapshots/worker.py +++ b/captain_hook/snapshots/worker.py @@ -40,6 +40,7 @@ class Pending: "hook": (8, 64), "review": (2, 16), "release": (4, 32), + "warm": (1, 4), } @@ -373,6 +374,8 @@ def submit(self, frame: dict[str, Any]) -> None: lane = ( "release" if request["operation"] == "release" + else "warm" + if request["operation"] in {"warm_registered", "warm_root"} else "review" if request["operation"] in {"prepare_review", "prepare_corrections"} else context["admission"] @@ -404,7 +407,7 @@ def run(self) -> None: with self.guard: for pending in self.pending.values(): pending.token.cancel() - for name in ("hook", "release"): + for name in ("hook", "release", "warm"): self.executors[name].shutdown(wait=True) self.executors["review"].submit(self.owner.close).result() self.executors["review"].shutdown(wait=True) diff --git a/captain_hook/testing/snapshots.py b/captain_hook/testing/snapshots.py index 8194b7d6..967bcbf5 100644 --- a/captain_hook/testing/snapshots.py +++ b/captain_hook/testing/snapshots.py @@ -33,6 +33,16 @@ def exchange(self, wrapper: dict[str, object]) -> dict[str, Any]: ) return checked("host-response", {"schema": HOST_SCHEMA, "response": result}) + def client_for_context(self, **overrides: str) -> SnapshotClient: + def exchange(wrapper: dict[str, object]) -> dict[str, Any]: + request = checked("host-request", wrapper) + result = self.owner.call( + request["request"], self.context | overrides, self.owner.token_type(), request["tool_registry"] + ) + return checked("host-response", {"schema": HOST_SCHEMA, "response": result}) + + return SnapshotClient(exchange) + def load(self, path: str | Path) -> RemoteSession: from captain_hook.transcripts import load_transcript diff --git a/captain_hook/transcripts.py b/captain_hook/transcripts.py index 74d17378..1127009f 100644 --- a/captain_hook/transcripts.py +++ b/captain_hook/transcripts.py @@ -1,6 +1,5 @@ from __future__ import annotations -import dataclasses import re import threading from pathlib import Path @@ -21,7 +20,7 @@ from cc_transcript.models import TranscriptEvent from cc_transcript.query import Session - from captain_hook.snapshots.client import RemoteSession, SnapshotClient + from captain_hook.snapshots.client import GraphSources, RemoteSession, SnapshotClient # A session id becomes a filesystem path component via ``ensure_session``; external callers (CLI, MCP) # must not smuggle path separators or traversal past that trust boundary. @@ -62,6 +61,18 @@ def transcript_session_id(events: Sequence[TranscriptEvent], *, path: Path | Non ) +def configured_classifier_policy(project_dir: str | Path | None) -> dict[str, str]: + from captain_hook.app import _state + from captain_hook.cli import CliState + from captain_hook.daemon.registry import Fingerprint + + return { + "id": "captain-configured", + "version": _state.registry_fingerprint + or Fingerprint.compute(CliState(root=Path(project_dir) if project_dir else reqenv.cwd())).digest, + } + + @overload def load_transcript(path: str | Path) -> RemoteSession: ... @@ -73,7 +84,7 @@ def load_transcript(path: None) -> Session: ... def load_transcript(path: str | Path | None) -> Session | RemoteSession: from cc_transcript.query import Session - from captain_hook.snapshots.client import CURRENT_CLIENT, EvidenceIncomplete + from captain_hook.snapshots.client import CURRENT_CLIENT, NATIVE_CLASSIFIER, EvidenceIncomplete if not path: return Session(()) @@ -81,18 +92,15 @@ def load_transcript(path: str | Path | None) -> Session | RemoteSession: raise EvidenceIncomplete("invalid_request", "transcript loading requires an admitted snapshot client") from captain_hook.app import _state - session = client.acquire(path) + try: + session = client.acquire(path) + except EvidenceIncomplete as exc: + if exc.status in {"incomplete", "deadline"}: + client.schedule_root_warm(path, NATIVE_CLASSIFIER) + raise try: if _state.classifier is not None: - from captain_hook.cli import CliState - from captain_hook.daemon.registry import Fingerprint - - project_dir = resolve_project_dir() - policy = { - "id": "captain-configured", - "version": _state.registry_fingerprint - or Fingerprint.compute(CliState(root=Path(project_dir) if project_dir else reqenv.cwd())).digest, - } + policy = configured_classifier_policy(resolve_project_dir()) return client.classify(session, _state.classifier, policy) data = list( client.pages( @@ -106,6 +114,11 @@ def load_transcript(path: str | Path | None) -> Session | RemoteSession: if len(data) != 1 or data[0]["kind"] != "classifier": raise EvidenceIncomplete("invalid_request", "hook classifier preparation returned invalid evidence") return session.with_classifier(data[0]["classifier"]) + except EvidenceIncomplete as exc: + if exc.status in {"incomplete", "deadline"}: + client.schedule_root_warm(path, session.classifier) + session.release() + raise except BaseException: session.release() raise @@ -239,12 +252,12 @@ def lazy_transcript( path: str | Path | None, *, loader: Callable[[str | Path | None], Session | RemoteSession] | None = None, - attach: Callable[[], Sequence[Path]] | None = None, + attach: Callable[[], GraphSources] | None = None, ) -> LazyTranscript: resolve = loader or load_transcript def load() -> Session | RemoteSession: - from captain_hook.snapshots.client import EvidenceIncomplete + from captain_hook.snapshots.client import EvidenceIncomplete, RemoteSession reqenv.checkpoint() try: @@ -253,8 +266,8 @@ def load() -> Session | RemoteSession: raise except Exception as exc: raise TranscriptLoadError(path) from exc - if attach and (extra := tuple(attach())): - session = dataclasses.replace(session, attachments=(*session.attachments, *extra)) + if attach and isinstance(session, RemoteSession): + session = session.with_registered_sources(attach()) return session return LazyTranscript(TranscriptPins(load), seed=True) @@ -301,6 +314,20 @@ def register_transcript( return entry +def registered_sources(session_dir: Path | None) -> GraphSources: + from cc_transcript.codex import sessions_root + + from captain_hook.snapshots.client import GraphSources + + entries = SessionSlot(session_dir, RegisteredTranscripts).get(RegisteredTranscripts()).entries + return GraphSources( + thread_ids=tuple(dict.fromkeys(entry.thread_id for entry in entries if entry.thread_id)), + roots=(sessions_root(),) if any(entry.thread_id for entry in entries) else (), + direct_paths=tuple(dict.fromkeys(Path(entry.path) for entry in entries if entry.path)), + session_key=str(session_dir) if session_dir is not None else None, + ) + + def resolved_transcript_paths( client: SnapshotClient, session_ids: Sequence[SessionId], *, roots: Sequence[Path] ) -> dict[SessionId, Path | None]: diff --git a/captain_hook/worker/runtime.py b/captain_hook/worker/runtime.py index bdc7426d..379d232b 100644 --- a/captain_hook/worker/runtime.py +++ b/captain_hook/worker/runtime.py @@ -18,7 +18,7 @@ from captain_hook.daemon.registry import Registry from captain_hook.dispatch import envelope_text from captain_hook.session import ensure_session -from captain_hook.snapshots.client import CURRENT_CLIENT, AttachmentLimit, EvidenceIncomplete +from captain_hook.snapshots.client import CURRENT_CLIENT, EvidenceIncomplete, GraphEvidenceExpired from captain_hook.state import RESOURCES from captain_hook.transcripts import load_transcript from captain_hook.types import Event @@ -39,6 +39,21 @@ class RegistryLike(Protocol): def get(self) -> Any: ... +FAIL_OPEN_EVIDENCE_STATUSES = frozenset( + { + "incomplete", + "source_limit", + "entry_limit", + "output_limit", + "deadline", + "cancelled", + "changed", + "retained_limit", + "lease_limit", + } +) + + @dataclass(frozen=True, slots=True) class _Client: ppid: int @@ -122,13 +137,8 @@ def _respond(self, request: EventRequest, abandoned: list[str]) -> tuple[EventRe except SystemExit as exc: return self._response(buffers, exit_code=_exit_code(exc.code)), None except EvidenceIncomplete as exc: - if isinstance(exc, AttachmentLimit): - logger.bind(status=exc.status, reason=exc.reason).warning( - "registered transcript attachment bound exceeded" - ) - return EventResponse(), None - if exc.status in {"retained_limit", "lease_limit"}: - logger.bind(status=exc.status, reason=exc.reason).warning("snapshot admission saturated") + if isinstance(exc, GraphEvidenceExpired) or exc.status in FAIL_OPEN_EVIDENCE_STATUSES: + logger.bind(status=exc.status, reason=exc.reason).warning("snapshot evidence incomplete") return EventResponse(), None buffers.stderr.write(traceback.format_exc()) return self._response(buffers, status="error", exit_code=1), None @@ -211,11 +221,8 @@ def _run_detached(background: Background) -> None: try: background() except EvidenceIncomplete as exc: - if isinstance(exc, AttachmentLimit): - logger.bind(status=exc.status, reason=exc.reason).warning("post-reply attachment bound exceeded") - return - if exc.status in {"retained_limit", "lease_limit"}: - logger.bind(status=exc.status, reason=exc.reason).warning("post-reply snapshot admission saturated") + if isinstance(exc, GraphEvidenceExpired) or exc.status in FAIL_OPEN_EVIDENCE_STATUSES: + logger.bind(status=exc.status, reason=exc.reason).warning("post-reply snapshot evidence incomplete") return logger.bind(status=exc.status, reason=exc.reason).error("post-reply evidence incomplete") raise diff --git a/captain_hook/worker/service.py b/captain_hook/worker/service.py index 664ad98a..1d3722c9 100644 --- a/captain_hook/worker/service.py +++ b/captain_hook/worker/service.py @@ -1,11 +1,14 @@ from __future__ import annotations +import hashlib +import json import threading import time import traceback +from collections import deque from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError from contextvars import ContextVar -from dataclasses import replace +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any from captain_hook.worker.protocol import ( @@ -31,17 +34,34 @@ ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Mapping + from pathlib import Path from typing import BinaryIO + from captain_hook.snapshots.client import GraphSources, RegisteredWarmState, RootWarmState, SnapshotClient + type Background = Callable[[], None] type Dispatch = Callable[[EventRequest], tuple[EventResponse, Background | None]] REQUEST_THREADS = 16 MAX_PENDING_SNAPSHOTS = 256 +MAX_PENDING_CLEANUPS = 64 +MAX_WARM_JOBS = 32 +WARM_STEP_BYTES = 8 * 1024 * 1024 +WARM_STEP_SECONDS = 3 +WARM_INTERVAL_SECONDS = 2 BACKGROUND_SNAPSHOT_CLIENT: ContextVar[Any] = ContextVar("background_snapshot_client", default=None) +@dataclass +class WarmJob: + owner_key: str + fingerprint: str + state: RegisteredWarmState | RootWarmState + admitted_order: int = 0 + last_served: int = 0 + + def handshake(input_stream: BinaryIO, output_stream: BinaryIO, *, build: str) -> bool: if (first := read_message(input_stream)) is None: return False @@ -66,6 +86,16 @@ def __init__( self._dispatch = dispatch self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="capt-hook-worker") self._background = ThreadPoolExecutor(max_workers=4, thread_name_prefix="capt-hook-async") + self._cleanup = ThreadPoolExecutor(max_workers=2, thread_name_prefix="capt-hook-cleanup") + self._cleanup_slots = threading.BoundedSemaphore(MAX_PENDING_CLEANUPS) + self._warm_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="capt-hook-graph-warm") + self._warm_guard = threading.Lock() + self._warm_jobs: dict[str, WarmJob] = {} + self._warm_queue: deque[str] = deque() + self._warm_queued: set[str] = set() + self._warm_running = False + self._warm_order = 0 + self._warm_stop = threading.Event() self._write_guard = threading.Lock() self._guard = threading.Condition() self._outstanding = 0 @@ -84,10 +114,14 @@ def run(self) -> None: else: self._submit(decode_event(message)) finally: + with self._warm_guard: + self._warm_stop.set() self._close_snapshots() self._drain() self._executor.shutdown() self._background.shutdown(wait=True) + self._cleanup.shutdown(wait=True) + self._warm_executor.shutdown(wait=True) if self._failure is not None: raise self._failure @@ -104,20 +138,48 @@ def _serve(self, request: EventRequest) -> None: if request.deadline_passed(): self._write(error_response(request.id, "deadline passed before dispatch")) return + from loguru import logger + start = time.perf_counter() - from captain_hook.snapshots.client import CURRENT_CLIENT, SnapshotClient + from captain_hook.snapshots.client import CURRENT_CLIENT, GRAPH_READ_BYTES, GRAPH_WORK_SECONDS, SnapshotClient def cleanup(value: dict[str, object]) -> dict[str, Any]: return self.snapshot_exchange(0, value, cleanup=True) - foreground = SnapshotClient(lambda value: self.snapshot_exchange(request.id, value), cleanup_exchange=cleanup) - background_client = SnapshotClient(lambda value: self.snapshot_exchange(0, value), cleanup_exchange=cleanup) + foreground = SnapshotClient( + lambda value: self.snapshot_exchange( + request.id, value, expires_unix_ms=foreground.foreground_deadline_unix_ms + ), + cleanup_exchange=cleanup, + warm_scheduler=self.schedule_graph_warm, + root_warm_scheduler=self.schedule_root_warm, + defer_cleanup=True, + foreground_seconds=GRAPH_WORK_SECONDS, + foreground_read_bytes=GRAPH_READ_BYTES, + ) + background_client = SnapshotClient( + lambda value: self.snapshot_exchange(0, value), + cleanup_exchange=cleanup, + warm_scheduler=self.schedule_graph_warm, + root_warm_scheduler=self.schedule_root_warm, + ) + + def retry_pending_cleanup() -> None: + for client in (foreground, background_client): + try: + client.close_pending() + except Exception: + logger.exception("snapshot client cleanup failed") + token = CURRENT_CLIENT.set(foreground) background_token = BACKGROUND_SNAPSHOT_CLIENT.set(background_client) try: response, background = self._dispatch(request) except Exception: - self._write(error_response(request.id, traceback.format_exc())) + try: + self._write(error_response(request.id, traceback.format_exc())) + finally: + self._schedule_cleanup(retry_pending_cleanup) return finally: CURRENT_CLIENT.reset(token) @@ -125,7 +187,10 @@ def cleanup(value: dict[str, object]) -> dict[str, Any]: elapsed_ms = (time.perf_counter() - start) * 1000 result = result_response(request.id, replace(response, elapsed_ms=elapsed_ms)) if background is None: - self._write(result) + try: + self._write(result) + finally: + self._schedule_cleanup(retry_pending_cleanup) return self._write(background_begin_message(request.id)) with self._guard: @@ -134,17 +199,24 @@ def cleanup(value: dict[str, object]) -> dict[str, Any]: self._write(result) future = self._background.submit(background) except BaseException: - self._end_background(request.id) + retry_pending_cleanup() + try: + self._end_background(request.id) + except Exception: + logger.exception("background completion failed") raise - future.add_done_callback(lambda completed: self._background_done(request.id, completed)) + future.add_done_callback(lambda completed: self._background_done(request.id, completed, retry_pending_cleanup)) - def _background_done(self, request_id: int, future: Future[None]) -> None: + def _background_done( + self, request_id: int, future: Future[None], retry_pending_cleanup: Callable[[], None] + ) -> None: try: if (exc := future.exception()) is not None: with self._guard: if self._failure is None: self._failure = exc finally: + retry_pending_cleanup() try: self._end_background(request_id) except BaseException as exc: @@ -160,6 +232,19 @@ def _end_background(self, request_id: int) -> None: self._background_outstanding -= 1 self._guard.notify_all() + def _schedule_cleanup(self, cleanup: Callable[[], None]) -> None: + if not self._cleanup_slots.acquire(blocking=False): + cleanup() + return + + def run() -> None: + try: + cleanup() + finally: + self._cleanup_slots.release() + + self._cleanup.submit(run) + def _done(self, future: Future[None]) -> None: with self._guard: if (exc := future.exception()) is not None and self._failure is None: @@ -168,6 +253,104 @@ def _done(self, future: Future[None]) -> None: if self._outstanding == 0: self._guard.notify_all() + def schedule_graph_warm(self, client: SnapshotClient, sources: GraphSources) -> None: + from captain_hook.snapshots.client import RegisteredWarmState + + if not sources.thread_ids and not sources.direct_paths: + return + descriptor = { + "thread_ids": sources.thread_ids, + "roots": [str(root) for root in sources.roots], + "direct_paths": [str(path) for path in sources.direct_paths], + "tool_registry": client.tool_registry(), + } + fingerprint = hashlib.sha256(json.dumps(descriptor, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + owner_key = sources.session_key or fingerprint + warm_client = client.clone_for_exchange(lambda value: self.snapshot_exchange(0, value)) + self._schedule_warm_job(owner_key, fingerprint, RegisteredWarmState(sources, warm_client)) + + def schedule_root_warm(self, client: SnapshotClient, path: Path, classifier: Mapping[str, str]) -> None: + from captain_hook.snapshots.client import RootWarmState + + descriptor = { + "path": str(path.absolute()), + "classifier": dict(classifier), + "tool_registry": client.tool_registry(), + } + fingerprint = hashlib.sha256(json.dumps(descriptor, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + owner_key = f"root:{descriptor['path']}:{classifier['id']}:{classifier['version']}" + warm_client = client.clone_for_exchange(lambda value: self.snapshot_exchange(0, value)) + self._schedule_warm_job(owner_key, fingerprint, RootWarmState(path, dict(classifier), warm_client)) + + def _schedule_warm_job(self, owner_key: str, fingerprint: str, state: RegisteredWarmState | RootWarmState) -> None: + with self._warm_guard: + if self._warm_stop.is_set(): + return + previous = self._warm_jobs.get(owner_key) + if previous is not None and previous.fingerprint == fingerprint: + return + if previous is None and len(self._warm_jobs) >= MAX_WARM_JOBS: + victim = max(self._warm_jobs.values(), key=lambda job: (job.last_served, job.admitted_order)) + del self._warm_jobs[victim.owner_key] + if victim.owner_key in self._warm_queued: + self._warm_queue.remove(victim.owner_key) + self._warm_queued.remove(victim.owner_key) + self._warm_order += 1 + self._warm_jobs[owner_key] = WarmJob(owner_key, fingerprint, state, admitted_order=self._warm_order) + if owner_key not in self._warm_queued: + self._warm_queue.append(owner_key) + self._warm_queued.add(owner_key) + if not self._warm_running: + self._warm_running = True + self._warm_executor.submit(self._run_warm_loop) + + def _run_warm_loop(self) -> None: + from loguru import logger + + from captain_hook.snapshots.client import EvidenceIncomplete + + while not self._warm_stop.is_set(): + with self._warm_guard: + if not self._warm_queue: + self._warm_running = False + return + owner_key = self._warm_queue.popleft() + self._warm_queued.remove(owner_key) + job = self._warm_jobs[owner_key] + try: + complete = self._warm_step(job) + failed = False + except EvidenceIncomplete as exc: + logger.bind(status=exc.status, reason=exc.reason).warning("transcript warming stopped") + complete = True + failed = True + except Exception: + logger.exception("transcript warming failed") + complete = True + failed = True + with self._warm_guard: + current = self._warm_jobs.get(owner_key) is job + if current: + if complete: + del self._warm_jobs[owner_key] + elif owner_key not in self._warm_queued: + self._warm_order += 1 + job.last_served = self._warm_order + self._warm_queue.append(owner_key) + self._warm_queued.add(owner_key) + if current and complete and not failed: + logger.bind(warm_key=job.fingerprint[:16], kind=type(job.state).__name__).info( + "transcript warming complete" + ) + interval = WARM_INTERVAL_SECONDS if job.state.last_usage.get("source_bytes_read", 0) else 0 + if self._warm_stop.wait(interval): + break + with self._warm_guard: + self._warm_running = False + + def _warm_step(self, job: WarmJob) -> bool: + return job.state.step(read_bytes=WARM_STEP_BYTES, deadline_seconds=WARM_STEP_SECONDS) + def _write(self, message: dict[str, object], *, max_frame: int | None = None) -> None: with self._write_guard: if max_frame is None: @@ -175,7 +358,9 @@ def _write(self, message: dict[str, object], *, max_frame: int | None = None) -> else: write_message(self._output, message, max_frame=max_frame) - def snapshot_exchange(self, parent_id: int, request: dict[str, Any], *, cleanup: bool = False) -> dict[str, Any]: + def snapshot_exchange( + self, parent_id: int, request: dict[str, Any], *, cleanup: bool = False, expires_unix_ms: int | None = None + ) -> dict[str, Any]: from captain_hook.snapshots.client import CLEANUP_SECONDS, EvidenceIncomplete, SnapshotProtocolError from captain_hook.util import reqenv @@ -194,6 +379,8 @@ def snapshot_exchange(self, parent_id: int, request: dict[str, Any], *, cleanup: ) if parent_id and (remaining := reqenv.seconds_left()) is not None: expires = min(expires, time.time() + remaining) + if expires_unix_ms is not None: + expires = min(expires, expires_unix_ms / 1000) future: Future[dict[str, Any]] = Future() with self._snapshot_guard: if self._snapshot_closed: diff --git a/internal/hookd/snapshot_bridge.go b/internal/hookd/snapshot_bridge.go index d26468bf..ab36da09 100644 --- a/internal/hookd/snapshot_bridge.go +++ b/internal/hookd/snapshot_bridge.go @@ -22,7 +22,18 @@ func (p *hostProduct) transcript(ctx context.Context, req daemonkit.Request) (da case <-ctx.Done(): } }() - callContext := userSnapshotContext(fmt.Sprintf("review:%d", req.Session.ID()), "review", req.Caller.UID) + metadata, err := snapshots.Metadata(req.Body) + if err != nil { + return daemonkit.Reply{}, err + } + admission := "review" + if metadata.Operation == "warm_registered" || metadata.Operation == "warm_root" { + admission = "hook" + } + callContext := userSnapshotContext(fmt.Sprintf("review:%d", req.Session.ID()), admission, req.Caller.UID) + if metadata.Operation == "warm_registered" || metadata.Operation == "warm_root" { + callContext.WorkClass = "background" + } body, err := p.snapshots.call(ctx, req.Body, callContext) return daemonkit.Reply{Body: body}, err } diff --git a/internal/hookd/snapshot_owner.go b/internal/hookd/snapshot_owner.go index b2e93b87..5bf6d2f9 100644 --- a/internal/hookd/snapshot_owner.go +++ b/internal/hookd/snapshot_owner.go @@ -272,8 +272,10 @@ type snapshotService struct { closed bool hookSlots chan struct{} reviewSlots chan struct{} + warmSlots chan struct{} hookQueue chan struct{} reviewQueue chan struct{} + warmQueue chan struct{} releaseSlots chan struct{} releaseQueue chan struct{} config json.RawMessage @@ -288,6 +290,7 @@ func newSnapshotService(manager *workerManager) (*snapshotService, error) { service := &snapshotService{manager: manager, config: config, hookSlots: make(chan struct{}, 4), reviewSlots: make(chan struct{}, 1), hookQueue: make(chan struct{}, 64), reviewQueue: make(chan struct{}, 16), + warmSlots: make(chan struct{}, 1), warmQueue: make(chan struct{}, 4), releaseSlots: make(chan struct{}, 4), releaseQueue: make(chan struct{}, 64)} service.start = service.startOwner return service, nil @@ -391,6 +394,8 @@ func (s *snapshotService) call(ctx context.Context, request json.RawMessage, cal } if metadata.Operation == "release" { slots, queue = s.releaseSlots, s.releaseQueue + } else if metadata.Operation == "warm_registered" || metadata.Operation == "warm_root" { + slots, queue = s.warmSlots, s.warmQueue } select { case queue <- struct{}{}: diff --git a/internal/hookd/snapshot_reverse.go b/internal/hookd/snapshot_reverse.go index 53ea9d77..e4122fcd 100644 --- a/internal/hookd/snapshot_reverse.go +++ b/internal/hookd/snapshot_reverse.go @@ -62,7 +62,8 @@ func (w *workerClient) snapshotFrame(frame wireproto.Frame) error { defer cancel() return w.write(ctx, wireproto.Frame{Protocol: wireproto.Schema, Op: wireproto.OpError, ID: frame.ID, ParentID: frame.ParentID, Error: "captain: snapshot parent event is no longer active"}) } - if _, err := snapshots.Metadata(frame.Snapshot); err != nil { + metadata, err := snapshots.Metadata(frame.Snapshot) + if err != nil { return err } encoded, err := wireproto.Marshal(frame) @@ -73,8 +74,12 @@ func (w *workerClient) snapshotFrame(frame wireproto.Frame) error { return wireproto.ErrPayloadTooLarge } parentContext, admission := context.Background(), "review" + workClass := "" if frame.ParentID != 0 { parentContext, admission = event.ctx, "hook" + } else if metadata.Operation == "warm_registered" || metadata.Operation == "warm_root" { + admission = "hook" + workClass = "background" } ctx, cancel := context.WithCancel(parentContext) w.mu.Lock() @@ -98,6 +103,7 @@ func (w *workerClient) snapshotFrame(frame wireproto.Frame) error { go func() { defer cancel() callContext := userSnapshotContext(fmt.Sprintf("worker:%d", w.snapshotNamespace), admission, uint32(os.Geteuid())) + callContext.WorkClass = workClass body, err := service.call(ctx, frame.Snapshot, callContext) response := wireproto.Frame{Protocol: wireproto.Schema, Op: wireproto.OpSnapshotResult, ID: frame.ID, ParentID: frame.ParentID, Snapshot: body} if err != nil { diff --git a/internal/hookd/snapshot_test.go b/internal/hookd/snapshot_test.go index 01d000c7..e2b9c075 100644 --- a/internal/hookd/snapshot_test.go +++ b/internal/hookd/snapshot_test.go @@ -15,9 +15,12 @@ import ( "github.com/yasyf/captain-hook/internal/snapshots" "github.com/yasyf/captain-hook/internal/wireproto" + "github.com/yasyf/daemonkit" ) var statsRequest = json.RawMessage(`{"schema":"captain.transcript/1","tool_registry":[],"request":{"schema":"cc-transcript.snapshot/1","operation":"stats","id":"one"}}`) +var warmRequest = json.RawMessage(`{"schema":"captain.transcript/1","tool_registry":[],"request":{"schema":"cc-transcript.snapshot/1","operation":"warm_registered","id":"warm","classifier":{"id":"native","version":"1"},"thread_ids":["thread"],"roots":["/tmp"],"direct_paths":[],"start_index":0,"membership_revision":null,"deadline_unix_ms":9000000000000,"limits":{"max_read_bytes":8388608,"max_events":1000000,"max_items":65536,"max_output_bytes":16777216,"max_discovery_entries":50000,"max_sources":4096}}}`) +var warmRootRequest = json.RawMessage(`{"schema":"captain.transcript/1","tool_registry":[],"request":{"schema":"cc-transcript.snapshot/1","operation":"warm_root","id":"warm-root","path":"/tmp/root.jsonl","classifier":{"id":"native","version":"1"},"deadline_unix_ms":9000000000000,"limits":{"max_read_bytes":8388608,"max_events":1000000,"max_items":65536,"max_output_bytes":16777216,"max_discovery_entries":50000,"max_sources":4096}}}`) func fakeSnapshotOwner(t *testing.T, frames chan<- wireproto.Frame) (*snapshotOwner, net.Conn) { t.Helper() @@ -552,6 +555,86 @@ func TestBackgroundReverseSnapshotsUseReviewAdmission(t *testing.T) { } } +func TestBackgroundWarmUsesHookIdentityWithoutHookAdmission(t *testing.T) { + frames := make(chan wireproto.Frame, 8) + owner, ownerServer := fakeSnapshotOwner(t, frames) + service, err := newSnapshotService(&workerManager{lifetime: context.Background(), logWriter: io.Discard}) + if err != nil { + t.Fatal(err) + } + service.owner = owner + for range cap(service.hookSlots) { + service.hookSlots <- struct{}{} + } + for range cap(service.hookQueue) { + service.hookQueue <- struct{}{} + } + for range cap(service.reviewSlots) { + service.reviewSlots <- struct{}{} + } + workerConn, pythonConn := net.Pipe() + defer pythonConn.Close() + worker := &workerClient{conn: workerConn, snapshots: service, snapshotNamespace: 17, snapshotEvents: map[uint64]snapshotEvent{}, reverseSnapshots: map[uint64]reverseSnapshot{}} + defer workerConn.Close() + for index, body := range []json.RawMessage{warmRequest, warmRootRequest} { + id := uint64(10 + index) + if err := worker.snapshotFrame(wireproto.Frame{Protocol: 1, Op: wireproto.OpSnapshotRequest, ID: id, Snapshot: body}); err != nil { + t.Fatal(err) + } + request := receiveSnapshot(t, frames) + var callContext snapshots.CallContext + if err := json.Unmarshal(request.SnapshotContext, &callContext); err != nil { + t.Fatal(err) + } + if callContext.Admission != "hook" || callContext.WorkClass != "background" || callContext.Claimant != "worker:17" || callContext.Authority.Kind != "user" { + t.Fatalf("warm context=%+v", callContext) + } + failSnapshot(t, ownerServer, request.ID) + reply, err := wireproto.DecodeFrame(pythonConn) + if err != nil || reply.ID != id || reply.ParentID != 0 || reply.Op != wireproto.OpError { + t.Fatalf("warm reply=%+v err=%v", reply, err) + } + } + if len(service.hookSlots) != cap(service.hookSlots) || len(service.hookQueue) != cap(service.hookQueue) { + t.Fatal("warming consumed hook admission") + } + if len(service.reviewSlots) != cap(service.reviewSlots) || len(service.warmSlots) != 0 { + t.Fatal("warming consumed review admission or retained a warm slot") + } +} + +func TestTranscriptClientWarmUsesHookAdmission(t *testing.T) { + frames := make(chan wireproto.Frame, 8) + owner, ownerServer := fakeSnapshotOwner(t, frames) + service, err := newSnapshotService(&workerManager{lifetime: context.Background(), logWriter: io.Discard}) + if err != nil { + t.Fatal(err) + } + service.owner = owner + product := &hostProduct{snapshots: service} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + for _, body := range []json.RawMessage{warmRequest, warmRootRequest} { + done := make(chan error, 1) + go func() { + _, err := product.transcript(ctx, daemonkit.Request{Op: opTranscript, Body: body, Caller: daemonkit.Caller{UID: 501}}) + done <- err + }() + frame := receiveSnapshot(t, frames) + var callContext snapshots.CallContext + if err := json.Unmarshal(frame.SnapshotContext, &callContext); err != nil { + t.Fatal(err) + } + if callContext.Admission != "hook" || callContext.WorkClass != "background" || callContext.Authority.EffectiveUID != "501" { + t.Fatalf("operator warmer context=%+v", callContext) + } + failSnapshot(t, ownerServer, frame.ID) + if err := <-done; err == nil || err.Error() != "fixture" { + t.Fatalf("operator warmer response=%v", err) + } + } +} + func TestCaptainDomainRequestUsesGeneratedIngressSchema(t *testing.T) { request := map[string]any{"schema": "captain.transcript/1", "tool_registry": []any{}, "request": map[string]any{ "schema": "captain.transcript/1", "id": "one", "operation": "prepare_review", "deadline_unix_ms": time.Now().Add(time.Second).UnixMilli(), diff --git a/internal/snapshots/schema.go b/internal/snapshots/schema.go index fb585e2e..e19b4428 100644 --- a/internal/snapshots/schema.go +++ b/internal/snapshots/schema.go @@ -144,6 +144,7 @@ type Authority struct { type CallContext struct { Claimant string `json:"claimant"` Admission string `json:"admission"` + WorkClass string `json:"work_class,omitempty"` Authority Authority `json:"authority"` RegistryGeneration string `json:"registry_generation"` } diff --git a/internal/snapshots/schema/config.schema.json b/internal/snapshots/schema/config.schema.json index e1bdfb22..3e1003a7 100644 --- a/internal/snapshots/schema/config.schema.json +++ b/internal/snapshots/schema/config.schema.json @@ -51,6 +51,20 @@ "title": "Max Preparation Ms", "type": "integer" }, + "max_prepared_disk_bytes": { + "default": 2147483648, + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Max Prepared Disk Bytes", + "type": "integer" + }, + "max_prepared_fact_memory_bytes": { + "default": 134217728, + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Max Prepared Fact Memory Bytes", + "type": "integer" + }, "max_projection_bytes": { "default": 16777216, "exclusiveMinimum": 0, diff --git a/internal/snapshots/schema/context.schema.json b/internal/snapshots/schema/context.schema.json index 5fe9df5b..17f6b94e 100644 --- a/internal/snapshots/schema/context.schema.json +++ b/internal/snapshots/schema/context.schema.json @@ -96,6 +96,15 @@ "minLength": 1, "title": "Registry Generation", "type": "string" + }, + "work_class": { + "default": "foreground", + "enum": [ + "foreground", + "background" + ], + "title": "Work Class", + "type": "string" } }, "required": [ diff --git a/internal/snapshots/schema/host-request.schema.json b/internal/snapshots/schema/host-request.schema.json index 09051571..2040ba87 100644 --- a/internal/snapshots/schema/host-request.schema.json +++ b/internal/snapshots/schema/host-request.schema.json @@ -378,13 +378,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -392,6 +395,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1036,6 +1048,42 @@ "title": "Gauges", "type": "object" }, + "Core_GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Core_Handle": { "additionalProperties": false, "properties": { @@ -1251,13 +1299,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -1265,6 +1316,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1811,6 +1871,320 @@ "title": "PendingNamedTask", "type": "object" }, + "Core_PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/Core_PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "Core_PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "Core_PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, + "Core_PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "Core_PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "Core_PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "Core_PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "Core_PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "handle": { + "$ref": "#/$defs/Core_Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" + ], + "title": "PreparedView", + "type": "object" + }, "Core_Prior": { "additionalProperties": false, "properties": { @@ -1851,11 +2225,129 @@ } }, "required": [ - "kind", - "selection", - "count" + "kind", + "selection", + "count" + ], + "title": "Prompts", + "type": "object" + }, + "Core_QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/Core_PreparedInputs", + "has_command": "#/$defs/Core_PreparedListPredicate", + "has_command_regex": "#/$defs/Core_PreparedToolPredicate", + "has_edit": "#/$defs/Core_PreparedErrorPredicate", + "has_edit_to": "#/$defs/Core_PreparedListPredicate", + "has_error": "#/$defs/Core_PreparedErrorPredicate", + "has_override": "#/$defs/Core_PreparedOverridePredicate", + "has_read": "#/$defs/Core_PreparedToolPredicate", + "has_read_glob": "#/$defs/Core_PreparedListPredicate", + "has_skill": "#/$defs/Core_PreparedListPredicate", + "has_skill_suffix": "#/$defs/Core_PreparedListPredicate", + "has_tool": "#/$defs/Core_PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_PreparedInputs" + }, + { + "$ref": "#/$defs/Core_PreparedToolPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedListPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/Core_PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" ], - "title": "Prompts", + "title": "QueryGraph", "type": "object" }, "Core_QueryRequest": { @@ -1983,7 +2475,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/Core_View" + "$ref": "#/$defs/Core_PreparedView" } }, "required": [ @@ -2069,7 +2561,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -2085,7 +2578,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -3272,6 +3766,285 @@ "title": "WaitingResult", "type": "object" }, + "Core_WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "Core_WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, + "Core_WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "Core_WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" + }, "Core_WorkflowText": { "additionalProperties": false, "properties": { @@ -3378,13 +4151,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3392,6 +4168,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -3533,13 +4318,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3547,6 +4335,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -4141,13 +4938,17 @@ "hydrate": "#/$defs/Core_Hydrate", "locate": "#/$defs/Core_Locate", "mine": "#/$defs/Core_Mine", + "prepare_graph": "#/$defs/Core_PrepareGraph", "query": "#/$defs/Core_QueryRequest", + "query_graph": "#/$defs/Core_QueryGraph", "release": "#/$defs/Core_Release", "renew": "#/$defs/Core_Renew", "resolve": "#/$defs/Core_Resolve", "resume": "#/$defs/Core_Resume", "retain": "#/$defs/Core_Retain", - "stats": "#/$defs/Core_Stats" + "stats": "#/$defs/Core_Stats", + "warm_registered": "#/$defs/Core_WarmRegistered", + "warm_root": "#/$defs/Core_WarmRoot" }, "propertyName": "operation" }, @@ -4191,6 +4992,18 @@ { "$ref": "#/$defs/Core_QueryRequest" }, + { + "$ref": "#/$defs/Core_PrepareGraph" + }, + { + "$ref": "#/$defs/Core_QueryGraph" + }, + { + "$ref": "#/$defs/Core_WarmRegistered" + }, + { + "$ref": "#/$defs/Core_WarmRoot" + }, { "$ref": "#/$defs/Core_ActivityProbe" }, diff --git a/internal/snapshots/schema/host-response.schema.json b/internal/snapshots/schema/host-response.schema.json index 6f71fedf..6fdd5f39 100644 --- a/internal/snapshots/schema/host-response.schema.json +++ b/internal/snapshots/schema/host-response.schema.json @@ -378,13 +378,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -392,6 +395,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1036,6 +1048,42 @@ "title": "Gauges", "type": "object" }, + "Core_GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Core_Handle": { "additionalProperties": false, "properties": { @@ -1251,13 +1299,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -1265,6 +1316,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -1811,6 +1871,320 @@ "title": "PendingNamedTask", "type": "object" }, + "Core_PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/Core_PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "Core_PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "Core_PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, + "Core_PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "Core_PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "Core_PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "Core_PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "Core_PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "handle": { + "$ref": "#/$defs/Core_Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" + ], + "title": "PreparedView", + "type": "object" + }, "Core_Prior": { "additionalProperties": false, "properties": { @@ -1851,11 +2225,129 @@ } }, "required": [ - "kind", - "selection", - "count" + "kind", + "selection", + "count" + ], + "title": "Prompts", + "type": "object" + }, + "Core_QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/Core_GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/Core_PreparedInputs", + "has_command": "#/$defs/Core_PreparedListPredicate", + "has_command_regex": "#/$defs/Core_PreparedToolPredicate", + "has_edit": "#/$defs/Core_PreparedErrorPredicate", + "has_edit_to": "#/$defs/Core_PreparedListPredicate", + "has_error": "#/$defs/Core_PreparedErrorPredicate", + "has_override": "#/$defs/Core_PreparedOverridePredicate", + "has_read": "#/$defs/Core_PreparedToolPredicate", + "has_read_glob": "#/$defs/Core_PreparedListPredicate", + "has_skill": "#/$defs/Core_PreparedListPredicate", + "has_skill_suffix": "#/$defs/Core_PreparedListPredicate", + "has_tool": "#/$defs/Core_PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_PreparedInputs" + }, + { + "$ref": "#/$defs/Core_PreparedToolPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedListPredicate" + }, + { + "$ref": "#/$defs/Core_PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/Core_PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/Core_ToolBoundary", + "before_last_tool": "#/$defs/Core_ToolBoundary", + "current_turn": "#/$defs/Core_CurrentTurn", + "event_range": "#/$defs/Core_EventRange", + "prior": "#/$defs/Core_Prior", + "recent_events": "#/$defs/Core_Recent", + "recent_messages": "#/$defs/Core_Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Core_CurrentTurn" + }, + { + "$ref": "#/$defs/Core_Prior" + }, + { + "$ref": "#/$defs/Core_Recent" + }, + { + "$ref": "#/$defs/Core_ToolBoundary" + }, + { + "$ref": "#/$defs/Core_EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" ], - "title": "Prompts", + "title": "QueryGraph", "type": "object" }, "Core_QueryRequest": { @@ -1983,7 +2475,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/Core_View" + "$ref": "#/$defs/Core_PreparedView" } }, "required": [ @@ -2069,7 +2561,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -2085,7 +2578,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -3272,6 +3766,285 @@ "title": "WaitingResult", "type": "object" }, + "Core_WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "Core_WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Core_Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Core_Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, + "Core_WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "Core_WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" + }, "Core_WorkflowText": { "additionalProperties": false, "properties": { @@ -3378,13 +4151,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3392,6 +4168,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, @@ -3533,13 +4318,16 @@ "hydrated": "#/$defs/Core_Hydrated", "loading": "#/$defs/Core_Loading", "located": "#/$defs/Core_Located", + "prepared_graph": "#/$defs/Core_PreparedGraphResult", "records": "#/$defs/Core_RecordsResult", "released": "#/$defs/Core_Released", "renewed": "#/$defs/Core_Renewed", "resolved": "#/$defs/Core_Resolved", "scalar": "#/$defs/Core_ScalarResult", "stats": "#/$defs/Core_StatsResult", - "strings": "#/$defs/Core_StringsResult" + "strings": "#/$defs/Core_StringsResult", + "warmed_registry": "#/$defs/Core_WarmedRegistryResult", + "warmed_root": "#/$defs/Core_WarmedRootResult" }, "propertyName": "kind" }, @@ -3547,6 +4335,15 @@ { "$ref": "#/$defs/Core_Acquired" }, + { + "$ref": "#/$defs/Core_PreparedGraphResult" + }, + { + "$ref": "#/$defs/Core_WarmedRegistryResult" + }, + { + "$ref": "#/$defs/Core_WarmedRootResult" + }, { "$ref": "#/$defs/Core_Loading" }, diff --git a/internal/snapshots/schema/request.schema.json b/internal/snapshots/schema/request.schema.json index 2261864a..8bad21f0 100644 --- a/internal/snapshots/schema/request.schema.json +++ b/internal/snapshots/schema/request.schema.json @@ -489,6 +489,42 @@ "title": "EventRef", "type": "object" }, + "GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Handle": { "additionalProperties": false, "properties": { @@ -967,9 +1003,304 @@ } }, "required": [ - "kind" + "kind" + ], + "title": "PendingNamedTask", + "type": "object" + }, + "PrepareGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "prepare_graph", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + }, + "view": { + "$ref": "#/$defs/PreparedView" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "view", + "thread_ids", + "roots", + "direct_paths" + ], + "title": "PrepareGraph", + "type": "object" + }, + "PreparedErrorPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_error", + "has_edit" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "subagents" + ], + "title": "PreparedErrorPredicate", + "type": "object" + }, + "PreparedInputs": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "deep_predicate_inputs", + "title": "Kind", + "type": "string" + }, + "order": { + "const": "forward", + "title": "Order", + "type": "string" + } + }, + "required": [ + "kind", + "order" + ], + "title": "PreparedInputs", + "type": "object" + }, + "PreparedListPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_command", + "has_edit_to", + "has_skill", + "has_skill_suffix", + "has_read_glob" + ], + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "values": { + "items": { + "maxLength": 1048576, + "type": "string" + }, + "maxItems": 256, + "title": "Values", + "type": "array" + } + }, + "required": [ + "kind", + "values", + "subagents" + ], + "title": "PreparedListPredicate", + "type": "object" + }, + "PreparedOverridePredicate": { + "additionalProperties": false, + "properties": { + "invalidated_by": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 256, + "title": "Invalidated By", + "type": "array" + }, + "kind": { + "const": "has_override", + "title": "Kind", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + }, + "token": { + "maxLength": 1048576, + "title": "Token", + "type": "string" + } + }, + "required": [ + "kind", + "token", + "invalidated_by", + "subagents" + ], + "title": "PreparedOverridePredicate", + "type": "object" + }, + "PreparedToolPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "has_tool", + "has_read", + "has_command_regex" + ], + "title": "Kind", + "type": "string" + }, + "pattern": { + "maxLength": 1048576, + "title": "Pattern", + "type": "string" + }, + "subagents": { + "const": true, + "title": "Subagents", + "type": "boolean" + } + }, + "required": [ + "kind", + "pattern", + "subagents" + ], + "title": "PreparedToolPredicate", + "type": "object" + }, + "PreparedView": { + "additionalProperties": false, + "properties": { + "attachments": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 0, + "title": "Attachments", + "type": "array" + }, + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "handle": { + "$ref": "#/$defs/Handle" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/ToolBoundary", + "before_last_tool": "#/$defs/ToolBoundary", + "current_turn": "#/$defs/CurrentTurn", + "event_range": "#/$defs/EventRange", + "prior": "#/$defs/Prior", + "recent_events": "#/$defs/Recent", + "recent_messages": "#/$defs/Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CurrentTurn" + }, + { + "$ref": "#/$defs/Prior" + }, + { + "$ref": "#/$defs/Recent" + }, + { + "$ref": "#/$defs/ToolBoundary" + }, + { + "$ref": "#/$defs/EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "handle", + "classifier", + "selectors", + "attachments" ], - "title": "PendingNamedTask", + "title": "PreparedView", "type": "object" }, "Prior": { @@ -1019,6 +1350,124 @@ "title": "Prompts", "type": "object" }, + "QueryGraph": { + "additionalProperties": false, + "properties": { + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "handle": { + "$ref": "#/$defs/GraphHandle" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "query_graph", + "title": "Operation", + "type": "string" + }, + "query": { + "discriminator": { + "mapping": { + "deep_predicate_inputs": "#/$defs/PreparedInputs", + "has_command": "#/$defs/PreparedListPredicate", + "has_command_regex": "#/$defs/PreparedToolPredicate", + "has_edit": "#/$defs/PreparedErrorPredicate", + "has_edit_to": "#/$defs/PreparedListPredicate", + "has_error": "#/$defs/PreparedErrorPredicate", + "has_override": "#/$defs/PreparedOverridePredicate", + "has_read": "#/$defs/PreparedToolPredicate", + "has_read_glob": "#/$defs/PreparedListPredicate", + "has_skill": "#/$defs/PreparedListPredicate", + "has_skill_suffix": "#/$defs/PreparedListPredicate", + "has_tool": "#/$defs/PreparedToolPredicate" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/PreparedInputs" + }, + { + "$ref": "#/$defs/PreparedToolPredicate" + }, + { + "$ref": "#/$defs/PreparedListPredicate" + }, + { + "$ref": "#/$defs/PreparedOverridePredicate" + }, + { + "$ref": "#/$defs/PreparedErrorPredicate" + } + ], + "title": "Query" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "selectors": { + "items": { + "discriminator": { + "mapping": { + "after_last_tool": "#/$defs/ToolBoundary", + "before_last_tool": "#/$defs/ToolBoundary", + "current_turn": "#/$defs/CurrentTurn", + "event_range": "#/$defs/EventRange", + "prior": "#/$defs/Prior", + "recent_events": "#/$defs/Recent", + "recent_messages": "#/$defs/Recent" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CurrentTurn" + }, + { + "$ref": "#/$defs/Prior" + }, + { + "$ref": "#/$defs/Recent" + }, + { + "$ref": "#/$defs/ToolBoundary" + }, + { + "$ref": "#/$defs/EventRange" + } + ] + }, + "maxItems": 64, + "title": "Selectors", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "handle", + "selectors", + "query" + ], + "title": "QueryGraph", + "type": "object" + }, "QueryRequest": { "additionalProperties": false, "properties": { @@ -1144,7 +1593,7 @@ "type": "string" }, "view": { - "$ref": "#/$defs/View" + "$ref": "#/$defs/PreparedView" } }, "required": [ @@ -1191,7 +1640,8 @@ "kind": { "enum": [ "lease", - "reservation" + "reservation", + "graph" ] } } @@ -1207,7 +1657,8 @@ "enum": [ "lease", "reservation", - "cursor" + "cursor", + "graph" ], "title": "Kind", "type": "string" @@ -1807,6 +2258,156 @@ "title": "View", "type": "object" }, + "WarmRegistered": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "direct_paths": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 1024, + "title": "Direct Paths", + "type": "array" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "membership_revision": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Membership Revision" + }, + "operation": { + "const": "warm_registered", + "title": "Operation", + "type": "string" + }, + "roots": { + "items": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "type": "string" + }, + "maxItems": 64, + "title": "Roots", + "type": "array" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + }, + "start_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Start Index", + "type": "integer" + }, + "thread_ids": { + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 1024, + "title": "Thread Ids", + "type": "array" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "classifier", + "thread_ids", + "roots", + "direct_paths", + "start_index", + "membership_revision" + ], + "title": "WarmRegistered", + "type": "object" + }, + "WarmRoot": { + "additionalProperties": false, + "properties": { + "classifier": { + "$ref": "#/$defs/Classifier" + }, + "deadline_unix_ms": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "title": "Deadline Unix Ms", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "limits": { + "$ref": "#/$defs/Limits" + }, + "operation": { + "const": "warm_root", + "title": "Operation", + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/", + "title": "Path", + "type": "string" + }, + "schema": { + "const": "cc-transcript.snapshot/1", + "title": "Schema", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "deadline_unix_ms", + "limits", + "operation", + "path", + "classifier" + ], + "title": "WarmRoot", + "type": "object" + }, "WorkflowText": { "additionalProperties": false, "properties": { @@ -1849,13 +2450,17 @@ "hydrate": "#/$defs/Hydrate", "locate": "#/$defs/Locate", "mine": "#/$defs/Mine", + "prepare_graph": "#/$defs/PrepareGraph", "query": "#/$defs/QueryRequest", + "query_graph": "#/$defs/QueryGraph", "release": "#/$defs/Release", "renew": "#/$defs/Renew", "resolve": "#/$defs/Resolve", "resume": "#/$defs/Resume", "retain": "#/$defs/Retain", - "stats": "#/$defs/Stats" + "stats": "#/$defs/Stats", + "warm_registered": "#/$defs/WarmRegistered", + "warm_root": "#/$defs/WarmRoot" }, "propertyName": "operation" }, @@ -1899,6 +2504,18 @@ { "$ref": "#/$defs/QueryRequest" }, + { + "$ref": "#/$defs/PrepareGraph" + }, + { + "$ref": "#/$defs/QueryGraph" + }, + { + "$ref": "#/$defs/WarmRegistered" + }, + { + "$ref": "#/$defs/WarmRoot" + }, { "$ref": "#/$defs/ActivityProbe" }, diff --git a/internal/snapshots/schema/response.schema.json b/internal/snapshots/schema/response.schema.json index a247c78a..d88687ff 100644 --- a/internal/snapshots/schema/response.schema.json +++ b/internal/snapshots/schema/response.schema.json @@ -89,13 +89,16 @@ "hydrated": "#/$defs/Hydrated", "loading": "#/$defs/Loading", "located": "#/$defs/Located", + "prepared_graph": "#/$defs/PreparedGraphResult", "records": "#/$defs/RecordsResult", "released": "#/$defs/Released", "renewed": "#/$defs/Renewed", "resolved": "#/$defs/Resolved", "scalar": "#/$defs/ScalarResult", "stats": "#/$defs/StatsResult", - "strings": "#/$defs/StringsResult" + "strings": "#/$defs/StringsResult", + "warmed_registry": "#/$defs/WarmedRegistryResult", + "warmed_root": "#/$defs/WarmedRootResult" }, "propertyName": "kind" }, @@ -103,6 +106,15 @@ { "$ref": "#/$defs/Acquired" }, + { + "$ref": "#/$defs/PreparedGraphResult" + }, + { + "$ref": "#/$defs/WarmedRegistryResult" + }, + { + "$ref": "#/$defs/WarmedRootResult" + }, { "$ref": "#/$defs/Loading" }, @@ -513,6 +525,42 @@ "title": "Gauges", "type": "object" }, + "GraphHandle": { + "additionalProperties": false, + "properties": { + "complete": { + "const": true, + "title": "Complete", + "type": "boolean" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "title": "Graph Id", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "revision": { + "maxLength": 256, + "minLength": 1, + "title": "Revision", + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_epoch", + "revision", + "complete" + ], + "title": "GraphHandle", + "type": "object" + }, "Handle": { "additionalProperties": false, "properties": { @@ -645,13 +693,16 @@ "hydrated": "#/$defs/Hydrated", "loading": "#/$defs/Loading", "located": "#/$defs/Located", + "prepared_graph": "#/$defs/PreparedGraphResult", "records": "#/$defs/RecordsResult", "released": "#/$defs/Released", "renewed": "#/$defs/Renewed", "resolved": "#/$defs/Resolved", "scalar": "#/$defs/ScalarResult", "stats": "#/$defs/StatsResult", - "strings": "#/$defs/StringsResult" + "strings": "#/$defs/StringsResult", + "warmed_registry": "#/$defs/WarmedRegistryResult", + "warmed_root": "#/$defs/WarmedRootResult" }, "propertyName": "kind" }, @@ -659,6 +710,15 @@ { "$ref": "#/$defs/Acquired" }, + { + "$ref": "#/$defs/PreparedGraphResult" + }, + { + "$ref": "#/$defs/WarmedRegistryResult" + }, + { + "$ref": "#/$defs/WarmedRootResult" + }, { "$ref": "#/$defs/Loading" }, @@ -842,6 +902,25 @@ "title": "Location", "type": "object" }, + "PreparedGraphResult": { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "#/$defs/GraphHandle" + }, + "kind": { + "const": "prepared_graph", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "handle" + ], + "title": "PreparedGraphResult", + "type": "object" + }, "RecordsResult": { "additionalProperties": false, "properties": { @@ -1341,6 +1420,135 @@ ], "title": "WaitingResult", "type": "object" + }, + "WarmedRegistryResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "fact_cache_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Bytes", + "type": "integer" + }, + "fact_cache_write_bytes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Write Bytes", + "type": "integer" + }, + "fact_cache_writes": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Fact Cache Writes", + "type": "integer" + }, + "kind": { + "const": "warmed_registry", + "title": "Kind", + "type": "string" + }, + "membership_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Membership Revision", + "type": "string" + }, + "next_index": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Next Index", + "type": "integer" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "membership_revision", + "next_index", + "complete", + "source_offset", + "source_size", + "fact_cache_bytes", + "fact_cache_write_bytes", + "fact_cache_writes" + ], + "title": "WarmedRegistryResult", + "type": "object" + }, + "WarmedRootResult": { + "additionalProperties": false, + "properties": { + "complete": { + "title": "Complete", + "type": "boolean" + }, + "facts_complete": { + "title": "Facts Complete", + "type": "boolean" + }, + "kind": { + "const": "warmed_root", + "title": "Kind", + "type": "string" + }, + "owner_epoch": { + "maxLength": 256, + "minLength": 1, + "title": "Owner Epoch", + "type": "string" + }, + "source_offset": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Offset", + "type": "integer" + }, + "source_revision": { + "maxLength": 256, + "minLength": 1, + "title": "Source Revision", + "type": "string" + }, + "source_size": { + "maximum": 9007199254740991, + "minimum": 0, + "title": "Source Size", + "type": "integer" + } + }, + "required": [ + "kind", + "owner_epoch", + "source_revision", + "source_offset", + "source_size", + "complete", + "facts_complete" + ], + "title": "WarmedRootResult", + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/pyproject.toml b/pyproject.toml index e43d19a0..dedfb20d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ ] requires-python = ">=3.13" dependencies = [ - "cc-transcript[judge]==14.28.1", + "cc-transcript[judge]==14.28.3", "pydantic>=2.0", "jsonschema>=4.26", "pydantic-settings>=2.0", diff --git a/tests/snapshot_review_helpers.py b/tests/snapshot_review_helpers.py index 2c368eed..bf2eafc8 100644 --- a/tests/snapshot_review_helpers.py +++ b/tests/snapshot_review_helpers.py @@ -85,6 +85,7 @@ def consume(self, **kwargs): class FixtureOwner: def __init__(self): + self._defer_cleanup = False self._leases = set() self.policy = ReviewPolicy() self.snapshots = {} diff --git a/tests/test_prepared_graph_evidence.py b/tests/test_prepared_graph_evidence.py new file mode 100644 index 00000000..67e5a78f --- /dev/null +++ b/tests/test_prepared_graph_evidence.py @@ -0,0 +1,464 @@ +import time +from pathlib import Path + +import pytest + +from captain_hook.snapshots.client import ( + EvidenceIncomplete, + GraphSources, + Lease, + RemoteSession, + SnapshotClient, +) +from tests.test_snapshot_client import description + + +class RecordingClient(SnapshotClient): + def __init__(self, *, fail_prepare=False): + super().__init__(lambda _: {}) + self.requests = [] + self.cleanups = [] + self.prepare_count = 0 + self.fail_prepare = fail_prepare + self.fail_query = None + self.bind_tool_registry({}) + + def call(self, operation, **arguments): + assert operation == "release" + self.cleanups.append(arguments) + return {"status": "ok"} + + def pages(self, operation, **arguments): + self.requests.append((operation, arguments)) + if operation == "retain": + yield {"kind": "acquired", "description": description(lease="retained")} + elif operation == "prepare_graph": + self.prepare_count += 1 + if self.fail_prepare: + raise EvidenceIncomplete("incomplete", "source budget exhausted") + yield { + "kind": "prepared_graph", + "handle": {"graph_id": "graph", "owner_epoch": "owner", "revision": "rev", "complete": True}, + } + elif operation == "query_graph": + if self.fail_query is not None: + raise EvidenceIncomplete(self.fail_query, "prepared graph expired") + yield {"kind": "scalar", "value": False} + elif operation == "query": + yield {"kind": "scalar", "value": True} + else: + raise AssertionError(operation) + + +def session_with_sources(client, count): + source = description() + session = RemoteSession(client, Lease(client, source), Path(source["canonical_path"]), source["classifier"]) + return session.with_registered_sources(GraphSources(thread_ids=tuple(f"thread-{i}" for i in range(count)))) + + +@pytest.mark.parametrize("count", [1, 256, 918, 1024]) +def test_multiple_deep_checks_prepare_once_and_query_by_handle(count): + client = RecordingClient() + session = session_with_sources(client, count) + + assert session.has_edit_to("src/**") is False + assert session.has_read("README.md") is False + assert client.prepare_count == 1 + operation, prepare = client.requests[0] + assert operation == "prepare_graph" + assert prepare["thread_ids"] == [f"thread-{i}" for i in range(count)] + assert prepare["view"]["attachments"] == [] + assert prepare["limits"]["max_read_bytes"] == 1024 * 1024 + assert prepare["limits"]["max_discovery_entries"] == 50_000 + assert prepare["limits"]["max_sources"] == 4096 + assert 0 < prepare["deadline_unix_ms"] - int(time.time() * 1000) <= 750 + for operation, arguments in client.requests[1:]: + assert operation == "query_graph" + assert arguments["handle"] == {"graph_id": "graph", "owner_epoch": "owner", "revision": "rev", "complete": True} + assert arguments["deadline_unix_ms"] == prepare["deadline_unix_ms"] + assert arguments["limits"] == prepare["limits"] + assert "view" not in arguments + assert "thread_ids" not in arguments + assert "direct_paths" not in arguments + + +def test_incomplete_preparation_is_shared_without_a_second_read(): + client = RecordingClient(fail_prepare=True) + session = session_with_sources(client, 918) + + for query in (lambda: session.has_edit_to("src/**"), lambda: session.has_read("README.md")): + with pytest.raises(EvidenceIncomplete, match="source budget exhausted"): + query() + assert client.prepare_count == 1 + assert [operation for operation, _ in client.requests] == ["prepare_graph"] + + +def test_incomplete_graph_enqueues_registered_sources_for_warming(): + client = RecordingClient(fail_prepare=True) + queued = [] + client._warm_scheduler = lambda source_client, sources: queued.append((source_client, sources)) + session = session_with_sources(client, 918) + + with pytest.raises(EvidenceIncomplete, match="source budget exhausted"): + session.has_edit_to("src/**") + + assert queued == [(client, session.graph.sources)] + + +def test_cold_root_schedules_background_progress_and_fails_open(tmp_path): + from captain_hook.snapshots.client import CURRENT_CLIENT + from captain_hook.transcripts import load_transcript + + path = tmp_path / "large-root.jsonl" + scheduled = [] + client = SnapshotClient( + lambda _: pytest.fail("cold root should not complete in foreground"), + root_warm_scheduler=lambda source_client, source_path, classifier: scheduled.append( + (source_client, source_path, classifier) + ), + ) + client.acquire = lambda _: (_ for _ in ()).throw(EvidenceIncomplete("incomplete", "foreground byte budget")) + token = CURRENT_CLIENT.set(client) + try: + with pytest.raises(EvidenceIncomplete, match="foreground byte budget"): + load_transcript(path) + finally: + CURRENT_CLIENT.reset(token) + + assert scheduled == [(client, path, {"id": "native", "version": "1"})] + + +def test_graph_work_stays_inside_the_hook_deadline(): + from captain_hook.util import reqenv + + client = RecordingClient() + session = session_with_sources(client, 1) + deadline_unix_ms = int(time.time() * 1000) + 1400 + scope = reqenv.RequestOverrides({}, "/tmp", 0, "session", deadline_unix_ms=deadline_unix_ms) + + with reqenv.use_request(scope): + assert session.has_edit_to("src/**") is False + + assert client.requests[0][1]["deadline_unix_ms"] == deadline_unix_ms - 1000 + assert client.requests[1][1]["deadline_unix_ms"] == deadline_unix_ms - 1000 + + +def test_unrelated_event_does_not_prepare_registered_transcripts(tmp_path, monkeypatch): + from captain_hook import EditedSource, on + from captain_hook.cli import dispatch_event + from captain_hook.events import Event + + @on(Event.Stop, only_if=[EditedSource()]) + def stop_gate(_): + pytest.fail("unrelated Stop hook ran") + + monkeypatch.setattr( + "captain_hook.transcripts.registered_sources", lambda _: pytest.fail("registered sources were read") + ) + monkeypatch.setattr("captain_hook.cli.after_reply", lambda *args: None) + _, background = dispatch_event( + tmp_path, + Event.UserPromptSubmit, + {"session_id": "unrelated", "transcript_path": str(tmp_path / "main.jsonl")}, + session_dir=None, + transcript_loader=lambda _: pytest.fail("transcript was loaded"), + ) + background() + + +def test_local_query_view_cannot_contain_registry_paths(): + client = RecordingClient() + session = session_with_sources(client, 918) + + assert session.has_read("README.md", subagents=False) is True + assert session.view()["attachments"] == [] + assert client.requests == [ + ( + "query", + { + "view": session.view(), + "query": {"kind": "has_read", "pattern": "README.md", "subagents": False}, + }, + ) + ] + assert client.prepare_count == 0 + + +def test_sync_and_background_share_one_preparation_across_clients(tmp_path, monkeypatch): + from captain_hook.cli import dispatch_event + from captain_hook.events import Event + from captain_hook.snapshots.client import CURRENT_CLIENT + from captain_hook.transcripts import release_transcript + + calls = [] + source = description() + + class PhaseClient(RecordingClient): + def pages(self, operation, **arguments): + if operation == "retain": + calls.append((self.phase, operation, arguments)) + yield {"kind": "acquired", "description": description(lease="background")} + return + calls.append((self.phase, operation, arguments)) + yield from super().pages(operation, **arguments) + + def call(self, operation, **arguments): + calls.append((self.phase, operation, arguments)) + return {"status": "ok"} + + foreground = PhaseClient() + foreground.phase = "foreground" + background_client = PhaseClient() + background_client.phase = "background" + monkeypatch.setattr( + "captain_hook.transcripts.registered_sources", + lambda _: GraphSources(thread_ids=("thread-1",)), + ) + + def loader(_): + return RemoteSession( + foreground, + Lease(foreground, source), + Path(source["canonical_path"]), + source["classifier"], + ) + + def sync(_event, evt, session_dir): + assert evt.ctx.t.has_edit_to("src/**") is False + release_transcript(evt.ctx.transcript) + return None + + def after(_event, evt, raw, session_dir): + assert evt.ctx.t.has_read("README.md") is False + + monkeypatch.setattr("captain_hook.cli.dispatch", sync) + monkeypatch.setattr("captain_hook.cli.after_reply", after) + token = CURRENT_CLIENT.set(foreground) + try: + _, background = dispatch_event( + tmp_path, + Event.Stop, + {"session_id": "s-shared", "transcript_path": str(tmp_path / "root.jsonl")}, + session_dir=None, + transcript_loader=loader, + ) + phase_token = CURRENT_CLIENT.set(background_client) + try: + background() + finally: + CURRENT_CLIENT.reset(phase_token) + finally: + CURRENT_CLIENT.reset(token) + + assert sum(operation == "prepare_graph" for _, operation, _ in calls) == 1 + assert [(phase, operation) for phase, operation, _ in calls if operation == "query_graph"] == [ + ("foreground", "query_graph"), + ("background", "query_graph"), + ] + assert sum(operation == "release" and args.get("kind") == "graph" for _, operation, args in calls) == 1 + + +def test_classifier_change_preserves_prepared_graph_sources(monkeypatch): + client = RecordingClient() + session = session_with_sources(client, 918) + classifier = {"id": "configured", "version": "1"} + + def acquire(path, *, classifier): + changed = description(lease="classified", classifier=classifier) + return RemoteSession(client, Lease(client, changed), path, classifier) + + monkeypatch.setattr(client, "acquire", acquire) + classified = session.with_classifier(classifier) + + assert classified.graph.sources.thread_ids == session.graph.sources.thread_ids + assert classified.has_edit_to("src/**") is False + assert client.prepare_count == 1 + + +def test_graph_cleanup_uses_parentless_transport_after_foreground_closes(): + from captain_hook.snapshots.client import PreparedGraphEvidence, PreparedGraphHandle + from tests.test_snapshot_client import response + + cleanup = [] + + def foreground(_): + raise AssertionError("foreground transport was used after reply") + + def parentless(wrapper): + cleanup.append(wrapper["request"]) + return response(wrapper["request"], {"kind": "released", "released": True}) + + client = SnapshotClient(foreground, cleanup_exchange=parentless) + client.bind_tool_registry({}) + graph = PreparedGraphEvidence(GraphSources()) + graph.outcome = PreparedGraphHandle("prepared", "owner", "rev") + graph.client = client + + graph.release() + + assert len(cleanup) == 1 + assert cleanup[0]["operation"] == "release" + assert cleanup[0]["kind"] == "graph" + assert cleanup[0]["token"] == "prepared" + + +def test_remote_session_type_has_no_path_bearing_query_view(): + import inspect + from dataclasses import fields + + assert "attachments" not in {field.name for field in fields(RemoteSession)} + assert "deep" not in inspect.signature(RemoteSession.view).parameters + + +def test_query_graph_schema_requires_an_opaque_handle(): + from jsonschema import ValidationError + + from captain_hook.snapshots.client import CORE_SCHEMA, DEFAULT_LIMITS, HOST_SCHEMA + from captain_hook.snapshots.validation import validate + + request = { + "schema": CORE_SCHEMA, + "id": "graph-query", + "operation": "query_graph", + "deadline_unix_ms": 1_790_389_614_377, + "limits": DEFAULT_LIMITS, + "selectors": [], + "query": {"kind": "has_edit_to", "values": ["*"], "subagents": True}, + } + envelope = {"schema": HOST_SCHEMA, "request": request, "tool_registry": []} + + with pytest.raises(ValidationError): + validate("host-request", envelope) + validate( + "host-request", + envelope + | { + "request": request + | {"handle": {"graph_id": "g", "owner_epoch": "owner", "revision": "rev", "complete": True}} + }, + ) + + +def test_graph_call_preserves_its_explicit_work_budget(): + from captain_hook.snapshots.client import DEFAULT_LIMITS + from tests.test_snapshot_client import response + + requests = [] + + def exchange(wrapper): + request = wrapper["request"] + requests.append(request) + return response(request, {"kind": "scalar", "value": False}) + + client = SnapshotClient(exchange) + client.bind_tool_registry({}) + limits = DEFAULT_LIMITS | {"max_read_bytes": 32 * 1024 * 1024} + deadline_unix_ms = 1_790_389_614_377 + + client.call( + "query_graph", + handle={"graph_id": "g", "owner_epoch": "owner", "revision": "rev", "complete": True}, + selectors=[], + query={"kind": "has_edit_to", "values": ["*.py"], "subagents": True}, + limits=limits, + deadline_unix_ms=deadline_unix_ms, + ) + + assert requests[0]["limits"] == limits + assert requests[0]["deadline_unix_ms"] == deadline_unix_ms + + +def test_cancelled_branch_does_not_close_another_branches_graph(): + client = RecordingClient() + session = session_with_sources(client, 918) + survivor = session.retain() + + session.release() + assert not any(cleanup["kind"] == "graph" for cleanup in client.cleanups) + assert survivor.has_edit_to("src/**") is False + assert client.prepare_count == 1 + survivor.release() + assert sum(cleanup["kind"] == "graph" for cleanup in client.cleanups) == 1 + + +def test_classifier_change_after_graph_preparation_is_incomplete_without_reread(): + client = RecordingClient() + session = session_with_sources(client, 918) + assert session.has_edit_to("src/**") is False + + with pytest.raises(EvidenceIncomplete, match="classifier changed after graph preparation"): + session.with_classifier({"id": "configured", "version": "1"}) + assert client.prepare_count == 1 + + +def test_ordinary_query_schema_rejects_attachment_paths(): + from jsonschema import ValidationError + + from captain_hook.snapshots.client import CORE_SCHEMA, DEFAULT_LIMITS, HOST_SCHEMA + from captain_hook.snapshots.validation import validate + + view = { + "handle": description()["handle"], + "classifier": {"id": "native", "version": "1"}, + "selectors": [], + "attachments": [], + } + request = { + "schema": CORE_SCHEMA, + "id": "local-query", + "operation": "query", + "deadline_unix_ms": 1_790_389_614_377, + "limits": DEFAULT_LIMITS, + "view": view, + "query": {"kind": "event_count"}, + } + envelope = {"schema": HOST_SCHEMA, "request": request, "tool_registry": []} + + validate("host-request", envelope) + with pytest.raises(ValidationError): + validate( + "host-request", envelope | {"request": request | {"view": view | {"attachments": ["/tmp/read-again"]}}} + ) + + +def test_expired_prepared_graph_query_is_typed_for_fail_open(): + from captain_hook.snapshots.client import GraphEvidenceExpired + + client = RecordingClient() + client.fail_query = "stale_handle" + session = session_with_sources(client, 918) + + with pytest.raises(GraphEvidenceExpired, match="prepared graph expired"): + session.has_edit_to("src/**") + assert client.prepare_count == 1 + + +def test_failed_graph_release_retries_when_request_client_closes(monkeypatch): + from captain_hook.snapshots import client as snapshot_client + from captain_hook.snapshots.client import PreparedGraphEvidence, PreparedGraphHandle + from captain_hook.snapshots.worker import failure + from tests.test_snapshot_client import response + + releases = [] + + def cleanup(wrapper): + request = wrapper["request"] + releases.append(request) + if len(releases) == 1: + return {"schema": snapshot_client.HOST_SCHEMA, "response": failure(request["id"], "retained_limit", "busy")} + return response(request, {"kind": "released", "released": True}) + + client = SnapshotClient(lambda _: pytest.fail("foreground transport used"), cleanup_exchange=cleanup) + client.bind_tool_registry({}) + graph = PreparedGraphEvidence(GraphSources()) + graph.outcome = PreparedGraphHandle("graph", "owner", "rev") + graph.client = client + client._graphs.add(graph) + monkeypatch.setattr(snapshot_client, "CLEANUP_SECONDS", 0) + + graph.release() + assert not graph.native_released + client.close() + assert graph.native_released + assert len(releases) == 2 + assert not client._graphs diff --git a/tests/test_snapshot_client.py b/tests/test_snapshot_client.py index 34713bf8..950b3a78 100644 --- a/tests/test_snapshot_client.py +++ b/tests/test_snapshot_client.py @@ -1,4 +1,8 @@ +import json import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack from pathlib import Path from types import SimpleNamespace @@ -10,8 +14,13 @@ HOST_SCHEMA, MAX_VIEW_ATTACHMENTS, AttachmentLimit, + EvidenceIncomplete, + GraphEvidenceExpired, + GraphSources, Lease, + RegisteredWarmState, RemoteSession, + RootWarmState, SnapshotClient, ) from captain_hook.snapshots.worker import empty_usage, failure @@ -54,31 +63,6 @@ def description(lease="lease", classifier=None): } -@pytest.mark.parametrize("count", [256, 257, 918, MAX_VIEW_ATTACHMENTS]) -def test_deep_query_preserves_all_bounded_attachments(count): - from captain_hook.snapshots.validation import validate - - requests = [] - - def exchange(wrapper): - validate("host-request", wrapper) - request = wrapper["request"] - requests.append(request) - return response(request, {"kind": "scalar", "value": False}) - - client = SnapshotClient(exchange) - client.bind_tool_registry({}) - source = description() - attachments = tuple(Path(f"/tmp/attachment-{index}.jsonl") for index in range(count)) - session = RemoteSession( - client, Lease(client, source), Path(source["canonical_path"]), source["classifier"], attachments=attachments - ) - - assert session.has_edit_to("src/**") is False - assert requests[0]["view"]["attachments"] == [str(path) for path in attachments] - assert len(requests) == 1 - - def test_local_queries_do_not_send_registered_attachments(): requests = [] @@ -101,10 +85,9 @@ def exchange(wrapper): client = SnapshotClient(exchange) client.bind_tool_registry({}) source = description() - attachments = tuple(Path(f"/tmp/attachment-{index}.jsonl") for index in range(918)) session = RemoteSession( - client, Lease(client, source), Path(source["canonical_path"]), source["classifier"], attachments=attachments - ) + client, Lease(client, source), Path(source["canonical_path"]), source["classifier"] + ).with_registered_sources(GraphSources(thread_ids=tuple(f"thread-{index}" for index in range(918)))) assert len(session) == 1 assert session.has_edit_to("src/**", subagents=False) is True @@ -116,9 +99,10 @@ def test_deep_query_over_attachment_bound_is_incomplete_before_transport(): client = SnapshotClient(lambda _: pytest.fail("overbound view reached transport")) client.bind_tool_registry({}) source = description() - attachments = tuple(Path(f"/tmp/attachment-{index}.jsonl") for index in range(MAX_VIEW_ATTACHMENTS + 1)) session = RemoteSession( - client, Lease(client, source), Path(source["canonical_path"]), source["classifier"], attachments=attachments + client, Lease(client, source), Path(source["canonical_path"]), source["classifier"] + ).with_registered_sources( + GraphSources(thread_ids=tuple(f"thread-{index}" for index in range(MAX_VIEW_ATTACHMENTS + 1))) ) assert len(session.view()["attachments"]) == 0 @@ -126,21 +110,42 @@ def test_deep_query_over_attachment_bound_is_incomplete_before_transport(): session.has_edit_to("src/**") -def test_real_owner_walks_918_distinct_attachments_with_one_graph_request(tmp_path): - from dataclasses import replace - +def test_real_owner_reuses_prepared_graph(tmp_path, monkeypatch): from captain_hook.testing.snapshots import FixtureOwner from tests.helpers import raw_text from tests.test_snapshot_fixture_owner import write_messages + monkeypatch.setenv("TMPDIR", str(tmp_path)) source = tmp_path / "root.jsonl" write_messages(source, raw_text("user", "root")) - attachments = tuple(tmp_path / f"attachment-{index}.jsonl" for index in range(918)) + attachment_count = 9 + attachments = tuple(tmp_path / f"attachment-{index}.jsonl" for index in range(attachment_count)) for path in attachments: write_messages(path, raw_text("user", "attached")) fixture = FixtureOwner() + fixture.client.bind_tool_registry({}) try: - session = replace(fixture.load(source), attachments=attachments) + sources = GraphSources(direct_paths=attachments) + cold = fixture.load(source).with_registered_sources(sources) + before_cold = fixture.client.call("stats")["data"]["counters"] + try: + assert cold.has_edit_to("src/**") is False + except EvidenceIncomplete as incomplete: + assert incomplete.status in {"incomplete", "deadline"} + after_cold = fixture.client.call("stats")["data"]["counters"] + assert after_cold["source_bytes_read"] - before_cold["source_bytes_read"] <= 1024 * 1024 + cold.release() + + warmer = RegisteredWarmState(sources, fixture.client) + fixture.context["work_class"] = "background" + for _ in range(attachment_count // 8 + 16): + if warmer.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3): + break + else: + pytest.fail("registered fixture did not finish warming") + fixture.context["work_class"] = "foreground" + + session = fixture.load(source).with_registered_sources(sources) requests = [] exchange = fixture.client._exchange @@ -149,12 +154,291 @@ def record(request): return exchange(request) fixture.client._exchange = record + before = fixture.client.call("stats")["data"]["counters"] + session.graph.require(session) + prepared = fixture.client.call("stats")["data"]["counters"] + assert prepared["source_bytes_read"] == before["source_bytes_read"] assert session.has_edit_to("src/**") is False - graph_requests = [request for request in requests if request["operation"] == "query"] - assert len(graph_requests) == 1 - assert graph_requests[0]["view"]["attachments"] == [str(path) for path in attachments] + assert session.has_read("README.md") is False + after = fixture.client.call("stats")["data"]["counters"] + assert after["source_bytes_read"] == prepared["source_bytes_read"] + assert after["nonincremental_lowering_calls"] == prepared["nonincremental_lowering_calls"] + assert after["nonincremental_lowering_source_bytes"] == prepared["nonincremental_lowering_source_bytes"] + graph_preparations = [request for request in requests if request["operation"] == "prepare_graph"] + graph_queries = [request for request in requests if request["operation"] == "query_graph"] + assert len(graph_preparations) == 1 + assert len(graph_queries) == 2 + assert graph_preparations[0]["direct_paths"] == [str(path) for path in attachments] + assert all("view" not in request and "direct_paths" not in request for request in graph_queries) session.release() + warm = fixture.load(source).with_registered_sources(sources) + before_warm = fixture.client.call("stats")["data"]["counters"] + warm.graph.require(warm) + assert warm.has_edit_to("src/**") is False + after_warm = fixture.client.call("stats")["data"]["counters"] + assert after_warm["source_bytes_read"] == before_warm["source_bytes_read"] + assert after_warm["nonincremental_lowering_calls"] == before_warm["nonincremental_lowering_calls"] + warm.release() + finally: + fixture.close() + + +def test_registered_warming_reuses_facts_across_claimants_only_under_the_same_authority(tmp_path, monkeypatch): + from captain_hook.testing.snapshots import FixtureOwner + from tests.helpers import raw_text + from tests.test_snapshot_fixture_owner import write_messages + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + source = tmp_path / "root.jsonl" + attachment = tmp_path / "attachment.jsonl" + write_messages(source, raw_text("user", "root")) + write_messages(attachment, raw_text("user", "attached")) + fixture = FixtureOwner() + fixture.client.bind_tool_registry({}) + warm = RegisteredWarmState(GraphSources(direct_paths=(attachment,)), fixture.client) + try: + fixture.context["claimant"] = "operator" + fixture.context["work_class"] = "background" + assert warm.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is True + fixture.context["claimant"] = "foreground" + fixture.context["work_class"] = "foreground" + session = fixture.load(source).with_registered_sources(GraphSources(direct_paths=(attachment,))) + before = fixture.client.call("stats")["data"]["counters"] + assert session.has_edit_to("src/**") is False + after = fixture.client.call("stats")["data"]["counters"] + assert after["source_bytes_read"] == before["source_bytes_read"] + session.release() + + authority = fixture.context["authority"] + fixture.context["authority"] = { + "kind": "restricted_roots", + "effective_uid": authority["effective_uid"], + "roots": [str(tmp_path)], + } + fixture.context["claimant"] = "restricted" + fixture.context["work_class"] = "background" + restricted = RegisteredWarmState(GraphSources(direct_paths=(attachment,)), fixture.client) + before_restricted = fixture.client.call("stats")["data"]["counters"] + assert restricted.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is True + after_restricted = fixture.client.call("stats")["data"]["counters"] + assert after_restricted["source_bytes_read"] > before_restricted["source_bytes_read"] + finally: + fixture.close() + + +def test_native_root_warming_reuses_source_and_append_facts(tmp_path, monkeypatch): + from captain_hook.testing.helpers import fixture_line + from captain_hook.testing.snapshots import FixtureOwner + from tests.helpers import raw_text + from tests.test_snapshot_fixture_owner import write_messages + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + source = tmp_path / "root.jsonl" + write_messages(source, raw_text("user", "x" * (1024 * 1024))) + fixture = FixtureOwner() + fixture.client.bind_tool_registry({}) + warm_client = fixture.client_for_context(work_class="background", claimant="warmer") + warm_client.bind_tool_registry({}) + + def warm_root(classifier): + state = RootWarmState(source, classifier, warm_client) + for _ in range(16): + if state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3): + return + pytest.fail("root fixture did not finish warming") + + def foreground_p95(): + samples = [] + for _ in range(12): + started = time.perf_counter() + session = fixture.load(source) + try: + assert session.has_read("missing") is False + finally: + session.release() + samples.append(time.perf_counter() - started) + return sorted(samples)[11] + + try: + warm_root({"id": "native", "version": "1"}) + first = fixture.load(source) + classifier = first.classifier + first.release() + warm_root(classifier) + before = fixture.client.call("stats")["data"]["counters"] + warm_p95 = foreground_p95() + after = fixture.client.call("stats")["data"]["counters"] + assert after["source_bytes_read"] == before["source_bytes_read"] + print(f"prepared root foreground p95_ms={warm_p95 * 1000:.1f}") + assert warm_p95 < 0.75 + + with source.open("a") as stream: + stream.write(json.dumps(fixture_line(1, raw_text("user", "tail"))) + "\n") + before_append = fixture.client.call("stats")["data"]["counters"] + warm_root({"id": "native", "version": "1"}) + warm_root(classifier) + append_p95 = foreground_p95() + after_append = fixture.client.call("stats")["data"]["counters"] + assert after_append["source_bytes_read"] - before_append["source_bytes_read"] < 512 * 1024 + print(f"prepared root append foreground p95_ms={append_p95 * 1000:.1f}") + assert append_p95 < 0.75 + finally: + warm_client.close() + fixture.close() + + +def test_foreground_graph_latency_stays_bounded_while_registry_warms(tmp_path, monkeypatch): + from captain_hook.testing.snapshots import FixtureOwner + from tests.helpers import raw_text + from tests.test_snapshot_fixture_owner import write_messages + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + source = tmp_path / "root.jsonl" + write_messages(source, raw_text("user", "root")) + attachments = tuple(tmp_path / f"attachment-{index}.jsonl" for index in range(923)) + for index, path in enumerate(attachments): + write_messages(path, raw_text("user", "x" * (1024 * 1024 if index == 0 else 1024))) + fixture = FixtureOwner() + fixture.client.bind_tool_registry({}) + warm_client = fixture.client_for_context(work_class="background", claimant="warmer") + warm_client.bind_tool_registry({}) + sources = GraphSources(direct_paths=attachments) + start_together = threading.Barrier(2) + + def warm(): + start_together.wait() + state = RegisteredWarmState(sources, warm_client) + for _ in range(len(attachments) // 8 + 16): + if state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3): + return + pytest.fail("registered fixture did not finish warming") + + try: + samples = [] + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(warm) + start_together.wait() + for _ in range(30): + started = time.perf_counter() + session = fixture.load(source).with_registered_sources(sources) + try: + try: + assert session.has_edit_to("src/**") is False + except EvidenceIncomplete as exc: + assert exc.status in {"incomplete", "deadline"} or ( + isinstance(exc, GraphEvidenceExpired) and exc.status in {"stale_cursor", "stale_handle"} + ) + finally: + session.release() + samples.append(time.perf_counter() - started) + future.result(timeout=30) + p95 = sorted(samples)[28] + print(f"prepared graph foreground p95_ms={p95 * 1000:.1f}") + assert p95 < 1.0 + requests = [] + exchange = fixture.client._exchange + + def record(request): + requests.append(request["request"]["operation"]) + return exchange(request) + + fixture.client._exchange = record + before_warm_query = fixture.client.call("stats")["data"]["counters"] + session = fixture.load(source).with_registered_sources(sources) + try: + assert session.has_edit_to("src/**") is False + assert session.has_read("README.md") is False + finally: + session.release() + after_warm_query = fixture.client.call("stats")["data"]["counters"] + assert after_warm_query["source_bytes_read"] == before_warm_query["source_bytes_read"] + assert requests.count("prepare_graph") == 1 + assert requests.count("query_graph") == 2 + finally: + warm_client.close() + fixture.close() + + +def test_codex_append_warming_does_not_lower_the_whole_source(tmp_path, monkeypatch): + from captain_hook.testing.snapshots import FixtureOwner + from tests.helpers import raw_text + from tests.test_snapshot_fixture_owner import write_messages + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + source = tmp_path / "root.jsonl" + active = tmp_path / "active.jsonl" + write_messages(source, raw_text("user", "root")) + metadata = { + "timestamp": "2026-01-01T00:00:00Z", + "type": "session_meta", + "payload": {"id": "thread-append", "cwd": str(tmp_path), "originator": "codex_exec", "source": "exec"}, + } + initial = { + "timestamp": "2026-01-01T00:00:01Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "x" * (1024 * 1024)}, + } + active.write_text(json.dumps(metadata) + "\n" + json.dumps(initial) + "\n") + fixture = FixtureOwner() + fixture.client.bind_tool_registry({}) + warm_client = fixture.client_for_context(work_class="background", claimant="warmer") + warm_client.bind_tool_registry({}) + sources = GraphSources(direct_paths=(active,)) + state = RegisteredWarmState(sources, warm_client) + + try: + for _ in range(16): + if state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3): + break + else: + pytest.fail("active source did not finish initial warming") + before_append = fixture.client.call("stats")["data"]["counters"] + appended_samples = [] + append_cpu_ms = [] + for index in range(12): + with active.open("a") as stream: + stream.write( + json.dumps( + { + "timestamp": f"2026-01-01T00:00:{index + 2:02}Z", + "type": "event_msg", + "payload": { + "type": "user_message" if index % 2 == 0 else "agent_message", + "message": f"new-{index}", + }, + } + ) + + "\n" + ) + cpu_started = time.process_time() + for _ in range(16): + if state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3): + break + else: + pytest.fail("appended source did not finish warming") + started = time.perf_counter() + session = fixture.load(source).with_registered_sources(sources) + try: + assert session.has_edit_to("src/**") is False + finally: + session.release() + appended_samples.append(time.perf_counter() - started) + append_cpu_ms.append((time.process_time() - cpu_started) * 1000) + after_append = fixture.client.call("stats")["data"]["counters"] + append_p95 = sorted(appended_samples)[11] + lowered_bytes = ( + after_append["nonincremental_lowering_source_bytes"] - before_append["nonincremental_lowering_source_bytes"] + ) + source_bytes = after_append["source_bytes_read"] - before_append["source_bytes_read"] + print(f"prepared graph warm append foreground p95_ms={append_p95 * 1000:.1f}") + print(f"prepared graph warm append cpu_total_ms={sum(append_cpu_ms):.1f}") + print(f"prepared graph warm append source_bytes_read={source_bytes}") + print(f"prepared graph warm append nonincremental_lowering_source_bytes={lowered_bytes}") + assert append_p95 < 1.0 + assert source_bytes < 2 * 1024 * 1024 + assert lowered_bytes < 1024 * 1024 finally: + warm_client.close() fixture.close() @@ -182,6 +466,112 @@ def release(wrapper): assert "owner_epoch" not in cleanup[0] +def test_abandoned_foreground_cursor_waits_until_after_reply_for_cleanup(): + cleanup_started = threading.Event() + finish_cleanup = threading.Event() + + def exchange(wrapper): + return response(wrapper["request"], {"kind": "strings", "values": ["first"]}, cursor="cursor") + + def cleanup(wrapper): + cleanup_started.set() + assert finish_cleanup.wait(2) + return response(wrapper["request"], {"kind": "released", "released": True}) + + client = SnapshotClient(exchange, cleanup_exchange=cleanup, defer_cleanup=True) + client.bind_tool_registry({}) + pages = client.pages("query", view={}, query={}) + assert next(pages) == {"kind": "strings", "values": ["first"]} + pages.close() + assert not cleanup_started.is_set() + + future = ThreadPoolExecutor(max_workers=1) + try: + settled = future.submit(client.close_pending) + assert cleanup_started.wait(3) + assert not settled.done() + finish_cleanup.set() + settled.result(timeout=3) + finally: + finish_cleanup.set() + future.shutdown() + + +def test_foreground_budget_covers_root_acquire_and_classifier(): + requests = [] + + def exchange(wrapper): + request = wrapper["request"] + requests.append(request) + if request["operation"] == "acquire": + result = response(request, {"kind": "acquired", "description": description()}) + result["response"]["usage"]["source_bytes_read"] = 700 * 1024 + return result + if request["operation"] == "prepare_hook_view": + result = response(request, {"kind": "classifier", "classifier": {"id": "native", "version": "1"}}) + result["response"]["usage"]["source_bytes_read"] = 300 * 1024 + return result + result = response(request, {"kind": "scalar", "value": False}) + result["response"]["usage"]["source_bytes_read"] = 24 * 1024 + return result + + client = SnapshotClient(exchange, foreground_seconds=0.75, foreground_read_bytes=1024 * 1024) + client.bind_tool_registry({}) + session = client.acquire("/tmp/fixture.jsonl") + list(client.pages("prepare_hook_view", domain=True, view=session.view(), cwd="/tmp", droid=False)) + client.call("query", view=session.view(), query={"kind": "has_read", "pattern": "x", "subagents": False}) + with pytest.raises(EvidenceIncomplete, match="foreground transcript byte budget exhausted"): + client.call("stats") + + assert [request["operation"] for request in requests] == ["acquire", "prepare_hook_view", "query"] + assert [request["limits"]["max_read_bytes"] for request in requests] == [ + 1024 * 1024, + 324 * 1024, + 24 * 1024, + ] + assert len({request["deadline_unix_ms"] for request in requests}) == 1 + assert requests[0]["deadline_unix_ms"] <= int(time.time() * 1000) + 750 + + +def test_foreground_root_cursor_stops_before_a_second_read_step(): + operations = [] + + def exchange(wrapper): + request = wrapper["request"] + operations.append(request["operation"]) + result = response(request, {"kind": "strings", "values": []}, cursor="root-cursor") + result["response"]["usage"]["source_bytes_read"] = 1024 * 1024 + return result + + def cleanup(wrapper): + return response(wrapper["request"], {"kind": "released", "released": True}) + + client = SnapshotClient( + exchange, + cleanup_exchange=cleanup, + defer_cleanup=True, + foreground_seconds=0.75, + foreground_read_bytes=1024 * 1024, + ) + client.bind_tool_registry({}) + + with pytest.raises(EvidenceIncomplete, match="foreground transcript byte budget exhausted"): + list(client.pages("acquire", path="/tmp/large-root.jsonl", classifier={"id": "native", "version": "1"})) + assert operations == ["acquire"] + client.close_pending() + + +def test_expired_foreground_budget_never_sends_a_native_request(): + client = SnapshotClient( + lambda _: pytest.fail("expired foreground request was sent"), + foreground_seconds=0, + ) + client.bind_tool_registry({}) + + with pytest.raises(EvidenceIncomplete, match="foreground transcript deadline exhausted"): + client.call("acquire", path="/tmp/root.jsonl", classifier={"id": "native", "version": "1"}) + + def test_exitstack_lease_cleanup_retries_retained_limit(): requests = [] @@ -226,8 +616,6 @@ def cleanup(wrapper): raise ValueError("original failure") assert not lease.released client.close() - assert len(requests) == 1 - lease.release() assert lease.released assert len(requests) == 2 @@ -471,8 +859,6 @@ def exchange(wrapper): def test_selected_subagents_keep_child_classifiers_and_reuse_owned_leases(tmp_path): - from dataclasses import replace - from captain_hook.testing.snapshots import FixtureOwner from tests.helpers import raw_assistant, raw_text, raw_tool_use from tests.test_snapshot_fixture_owner import write_messages @@ -498,7 +884,7 @@ def test_selected_subagents_keep_child_classifiers_and_reuse_owned_leases(tmp_pa attachment.write_text("not json\n") fixture = FixtureOwner() try: - session = replace(fixture.load(source), attachments=(attachment,)) + session = fixture.load(source).with_registered_sources(GraphSources(direct_paths=(attachment,))) assert session.classifier == {"id": "captain-conductor", "version": "1"} index = session.current_turn.subagents assert [item.id for item in index] == ["second"] @@ -517,3 +903,55 @@ def test_selected_subagents_keep_child_classifiers_and_reuse_owned_leases(tmp_pa assert not fixture.client._leases finally: fixture.close() + + +def test_post_reply_cleanup_retries_only_deferred_leases(): + requests = [] + + def cleanup(wrapper): + request = wrapper["request"] + requests.append(request) + return response(request, {"kind": "released", "released": True}) + + client = SnapshotClient(lambda _: pytest.fail("foreground exchange used"), cleanup_exchange=cleanup) + client.bind_tool_registry({}) + active = Lease(client, description("active")) + deferred = Lease(client, description("deferred")) + deferred.cleanup_pending = True + + client.close_pending() + + assert not active.released + assert deferred.released + assert [request["token"] for request in requests] == ["deferred"] + + +def test_graph_query_budget_bounds_each_partial_step(tmp_path, monkeypatch): + from captain_hook.snapshots.client import DEFAULT_LIMITS, EvidenceIncomplete + from captain_hook.testing.snapshots import FixtureOwner + from tests.helpers import raw_text + from tests.test_snapshot_fixture_owner import write_messages + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + source = tmp_path / "root.jsonl" + attachment = tmp_path / "attachment.jsonl" + write_messages(source, raw_text("user", "root")) + write_messages(attachment, raw_text("user", "x" * 4096)) + fixture = FixtureOwner() + try: + session = fixture.load(source).with_registered_sources(GraphSources(direct_paths=(attachment,))) + monkeypatch.setitem(DEFAULT_LIMITS, "max_read_bytes", 256) + before = fixture.client.call("stats")["data"]["counters"] + with pytest.raises(EvidenceIncomplete) as first: + session.has_edit_to("src/**") + assert first.value.status in {"source_limit", "incomplete"} + after_first = fixture.client.call("stats")["data"]["counters"] + with pytest.raises(EvidenceIncomplete) as second: + session.has_read("README.md") + assert second.value.status in {"source_limit", "incomplete"} + after_second = fixture.client.call("stats")["data"]["counters"] + assert 0 <= after_first["source_bytes_read"] - before["source_bytes_read"] <= 256 + assert 0 <= after_second["source_bytes_read"] - after_first["source_bytes_read"] <= 256 + session.release() + finally: + fixture.close() diff --git a/tests/test_snapshot_command_batch.py b/tests/test_snapshot_command_batch.py index a82b1326..5273260d 100644 --- a/tests/test_snapshot_command_batch.py +++ b/tests/test_snapshot_command_batch.py @@ -33,7 +33,7 @@ def test_command_spellings_share_one_native_projection(tmp_path, subagents): def record(wrapper): request = wrapper["request"] - if request["operation"] == "query": + if request["operation"] in {"query", "query_graph"}: operations.append(request["query"]["kind"]) return exchange(wrapper) diff --git a/tests/test_snapshot_lifecycle.py b/tests/test_snapshot_lifecycle.py index 4eefec0e..c45e4362 100644 --- a/tests/test_snapshot_lifecycle.py +++ b/tests/test_snapshot_lifecycle.py @@ -49,7 +49,7 @@ def test_legacy_frame_decoder_preserves_exact_snapshot_numbers(lexeme, accepted) decode_snapshot_reply(decoded) -def test_after_reply_builds_fresh_transcript_in_background_client_scope(tmp_path, monkeypatch): +def test_after_reply_reuses_transcript_in_background_client_scope(tmp_path, monkeypatch): foreground, background = object(), object() clients = [] events = [] @@ -66,6 +66,7 @@ def sync(event, evt, **kwargs): def after(event, evt, raw, session_dir): events.append(evt) + assert CURRENT_CLIENT.get() is background assert evt.ctx.transcript.path == "/fixture.jsonl" assert evt.ctx.signal_evidence == {} evt.ctx.transcript.release() @@ -85,7 +86,7 @@ def after(event, evt, raw, session_dir): run_background() finally: CURRENT_CLIENT.reset(token) - assert clients == [foreground, background] + assert clients == [foreground] assert events[0] is not events[1] assert events[0].ctx is not events[1].ctx diff --git a/tests/test_snapshot_owner.py b/tests/test_snapshot_owner.py index c8694728..c001046f 100644 --- a/tests/test_snapshot_owner.py +++ b/tests/test_snapshot_owner.py @@ -61,6 +61,53 @@ def test_owner_handshake_has_exact_build_and_config(): handshake(io.BytesIO(encode_frame(hello)), io.BytesIO(), build="other") +def test_warm_registered_uses_its_own_owner_lane_with_hook_cache_identity(): + calls = [] + owner = SimpleNamespace( + token_type=threading.Event, + call=lambda body, ctx, token, registry: ( + calls.append((body, ctx)) or failure(body["id"], "incomplete", "bounded fixture") + ), + close=lambda: None, + record_transport=lambda count: None, + discard=lambda response, ctx: None, + ) + service = OwnerService(io.BytesIO(), io.BytesIO(), owner) + for lane in ("hook", "review"): + for _ in range(OWNER_ADMISSION[lane][1]): + assert service.slots[lane].acquire(blocking=False) + frame = request( + operation="warm_registered", + classifier={"id": "native", "version": "1"}, + thread_ids=["thread"], + roots=["/tmp"], + direct_paths=[], + start_index=0, + membership_revision=None, + deadline_unix_ms=9_000_000_000_000, + limits={ + "max_read_bytes": 8 * 1024 * 1024, + "max_events": 1_000_000, + "max_items": 65_536, + "max_output_bytes": 16 * 1024 * 1024, + "max_discovery_entries": 50_000, + "max_sources": 4096, + }, + ) + try: + service.submit(frame) + wait_idle(service) + assert len(calls) == 1 + assert calls[0][0]["operation"] == "warm_registered" + assert calls[0][1]["admission"] == "hook" + assert not service.slots["hook"].acquire(blocking=False) + assert not service.slots["review"].acquire(blocking=False) + assert service.slots["warm"].acquire(blocking=False) + finally: + for executor in service.executors.values(): + executor.shutdown() + + @pytest.mark.parametrize( "lexeme,accepted", [ diff --git a/tests/test_transcripts_registration.py b/tests/test_transcripts_registration.py index 30413d9b..02bcf6f6 100644 --- a/tests/test_transcripts_registration.py +++ b/tests/test_transcripts_registration.py @@ -7,7 +7,9 @@ import subprocess import sys import threading +import time from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -128,6 +130,286 @@ def test_register_rejects_traversal_session_id(self, bad: str) -> None: assert "invalid session id" in result.output +def test_warm_command_uses_registered_ids_and_only_prints_counters(tmp_path, monkeypatch): + from captain_hook.snapshots.client import SnapshotClient + + register_transcript("s-warm", thread_id="thread-1") + calls = [] + client = SnapshotClient(lambda _: {}) + client.bind_tool_registry({}) + + class RootSession: + def view(self): + return {"attachments": []} + + def release(self): + pass + + def warm(operation, **arguments): + calls.append((operation, arguments)) + if operation == "warm_root": + return { + "status": "ok", + "usage": {"source_bytes_read": 1 if len(calls) == 1 else 0, "cache_hits": 0}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": 100, + "source_size": 100, + "complete": True, + "facts_complete": True, + }, + } + return { + "status": "ok", + "usage": { + "source_bytes_read": 1 if len(calls) == 3 else 0, + "discovery_entries_examined": 0, + "cache_hits": 0 if len(calls) == 3 else 1, + }, + "data": { + "kind": "warmed_registry", + "owner_epoch": "owner", + "membership_revision": "revision", + "next_index": 1, + "complete": True, + "source_offset": 0, + "source_size": 0, + "fact_cache_bytes": 100, + "fact_cache_write_bytes": 50, + "fact_cache_writes": 1, + }, + } + + @contextmanager + def scope(): + yield client + + monkeypatch.setattr(client, "call", warm) + monkeypatch.setattr(client, "acquire", lambda _: RootSession()) + monkeypatch.setattr( + client, + "pages", + lambda *args, **kwargs: iter(({"kind": "classifier", "classifier": {"id": "native", "version": "1"}},)), + ) + monkeypatch.setattr( + "captain_hook.transcripts.resolved_transcript_paths", + lambda *_args, **_kwargs: {SessionId("s-warm"): tmp_path / "root.jsonl"}, + ) + monkeypatch.setattr("captain_hook.snapshots.client.client_scope", scope) + monkeypatch.setattr("captain_hook.cli.CliState.discover", lambda self: []) + result = CliRunner().invoke(cli, ["transcripts", "warm", "--session", "s-warm", "--root", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output) == { + "complete": True, + "root_steps": 1, + "root_source_bytes_read": 1, + "root_verify_source_bytes_read": 0, + "root_facts_complete": True, + "sources": 1, + "steps": 1, + "fact_cache_bytes": 100, + "fact_cache_write_bytes": 50, + "fact_cache_writes": 1, + "warm_source_bytes_read": 1, + "verify_complete": True, + "verify_steps": 1, + "verify_source_bytes_read": 0, + "verify_cache_hits": 1, + "elapsed_seconds": pytest.approx(0, abs=1), + "status": "complete", + } + assert [operation for operation, _ in calls] == ["warm_root", "warm_root", "warm_registered", "warm_registered"] + assert calls[2][1]["thread_ids"] == ["thread-1"] + assert calls[2][1]["limits"]["max_read_bytes"] == 8 * 1024 * 1024 + + +def test_warm_command_does_not_pace_cache_only_progress(tmp_path, monkeypatch): + from captain_hook.snapshots.client import SnapshotClient + + register_transcript("s-cached", thread_id="thread-1") + client = SnapshotClient(lambda _: {}) + client.bind_tool_registry({}) + calls = [] + + class RootSession: + def view(self): + return {"attachments": []} + + def release(self): + pass + + def warm(operation, **arguments): + calls.append((operation, arguments)) + if operation == "warm_root": + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "cache_hits": 1}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": 100, + "source_size": 100, + "complete": True, + "facts_complete": True, + }, + } + registry_calls = sum(name == "warm_registered" for name, _ in calls) + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "discovery_entries_examined": 0, "cache_hits": 8}, + "data": { + "kind": "warmed_registry", + "owner_epoch": "owner", + "membership_revision": "revision", + "next_index": 8 if registry_calls % 2 else 10, + "complete": registry_calls % 2 == 0, + "source_offset": 0, + "source_size": 0, + "fact_cache_bytes": 100, + "fact_cache_write_bytes": 0, + "fact_cache_writes": 0, + }, + } + + @contextmanager + def scope(): + yield client + + monkeypatch.setattr(client, "call", warm) + monkeypatch.setattr(client, "acquire", lambda _: RootSession()) + monkeypatch.setattr( + client, + "pages", + lambda *args, **kwargs: iter(({"kind": "classifier", "classifier": {"id": "native", "version": "1"}},)), + ) + monkeypatch.setattr( + "captain_hook.transcripts.resolved_transcript_paths", + lambda *_args, **_kwargs: {SessionId("s-cached"): tmp_path / "root.jsonl"}, + ) + monkeypatch.setattr("captain_hook.snapshots.client.client_scope", scope) + monkeypatch.setattr("captain_hook.cli.CliState.discover", lambda self: []) + monkeypatch.setattr("captain_hook.worker.service.WARM_INTERVAL_SECONDS", 10) + started = time.monotonic() + result = CliRunner().invoke(cli, ["transcripts", "warm", "--session", "s-cached", "--root", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert time.monotonic() - started < 1 + assert len(calls) == 6 + assert json.loads(result.output)["verify_cache_hits"] == 16 + + +def test_warm_command_prepares_root_classifier_without_registered_sources(tmp_path, monkeypatch): + from captain_hook.snapshots.client import SnapshotClient + + client = SnapshotClient(lambda _: {}) + client.bind_tool_registry({}) + calls = [] + + class RootSession: + def view(self): + return {"attachments": []} + + def release(self): + pass + + def warm(operation, **arguments): + calls.append((operation, arguments)) + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "cache_hits": 1}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": 100, + "source_size": 100, + "complete": True, + "facts_complete": True, + }, + } + + @contextmanager + def scope(): + yield client + + monkeypatch.setattr(client, "call", warm) + monkeypatch.setattr(client, "acquire", lambda _: RootSession()) + monkeypatch.setattr( + client, + "pages", + lambda *args, **kwargs: iter(({"kind": "classifier", "classifier": {"id": "captain-lane", "version": "1"}},)), + ) + monkeypatch.setattr( + "captain_hook.transcripts.resolved_transcript_paths", + lambda *_args, **_kwargs: {SessionId("root-only"): tmp_path / "root.jsonl"}, + ) + monkeypatch.setattr("captain_hook.snapshots.client.client_scope", scope) + monkeypatch.setattr("captain_hook.cli.CliState.discover", lambda self: []) + result = CliRunner().invoke(cli, ["transcripts", "warm", "--session", "root-only", "--root", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert [arguments["classifier"]["id"] for _, arguments in calls] == [ + "native", + "native", + "captain-lane", + "captain-lane", + ] + assert json.loads(result.output)["root_facts_complete"] is True + assert json.loads(result.output)["sources"] == 0 + + +def test_warm_command_reports_unavailable_configured_classifier(tmp_path, monkeypatch): + from captain_hook.snapshots.client import SnapshotClient + + client = SnapshotClient(lambda _: {}) + client.bind_tool_registry({}) + calls = [] + + def warm(operation, **arguments): + calls.append((operation, arguments)) + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "cache_hits": 1}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": 100, + "source_size": 100, + "complete": True, + "facts_complete": True, + }, + } + + @contextmanager + def scope(): + yield client + + monkeypatch.setattr(client, "call", warm) + monkeypatch.setattr(client, "acquire", lambda _: pytest.fail("configured callback ran in operator")) + monkeypatch.setattr( + "captain_hook.transcripts.resolved_transcript_paths", + lambda *_args, **_kwargs: {SessionId("configured"): tmp_path / "root.jsonl"}, + ) + monkeypatch.setattr( + "captain_hook.transcripts.configured_classifier_policy", + lambda _: {"id": "captain-configured", "version": "digest"}, + ) + monkeypatch.setattr("captain_hook.snapshots.client.client_scope", scope) + monkeypatch.setattr("captain_hook.cli.CliState.discover", lambda self: []) + monkeypatch.setattr("captain_hook.cli._state.classifier", lambda _: True) + result = CliRunner().invoke(cli, ["transcripts", "warm", "--session", "configured", "--root", str(tmp_path)]) + + assert result.exit_code == 1 + assert json.loads(result.output)["status"] == "classifier_unavailable" + assert json.loads(result.output)["root_facts_complete"] is False + assert [arguments["classifier"]["id"] for _, arguments in calls] == ["native", "native"] + + class TestMcpTool: def test_mcp_tool_hits_the_same_slot(self) -> None: from captain_hook.mcp_server import build_mcp_server @@ -452,24 +734,38 @@ def bare_gate(evt): fixture.close() -def test_dispatch_reuses_registered_paths_between_sync_and_background(tmp_path, discovery_client, monkeypatch): - from cc_transcript.query import Session - - from captain_hook.transcripts import release_transcript +def test_dispatch_reads_registered_sources_once_across_sync_and_background(tmp_path, monkeypatch): + from captain_hook.snapshots.client import Lease, RemoteSession + from captain_hook.transcripts import registered_sources, release_transcript + from tests.test_prepared_graph_evidence import RecordingClient, description register_transcript("s-both", thread_id="first") register_transcript("s-both", thread_id="second") - discovery_client.results = { - "first": tmp_path / "first.jsonl", - "second": tmp_path / "second.jsonl", - } seen = [] + reads = [] + loads = [] + client = RecordingClient() + source = description() + + def sources(session_dir): + reads.append(session_dir) + return registered_sources(session_dir) + + def loader(_): + loads.append(True) + return RemoteSession( + client, + Lease(client, source), + Path(source["canonical_path"]), + source["classifier"], + ) def observe(_event, evt, session_dir): - seen.append(evt.ctx.t.attachments) + seen.append(evt.ctx.t.graph.sources.thread_ids) release_transcript(evt.ctx.transcript) return None + monkeypatch.setattr("captain_hook.transcripts.registered_sources", sources) monkeypatch.setattr("captain_hook.cli.dispatch", observe) monkeypatch.setattr( "captain_hook.cli.after_reply", lambda event, evt, raw, session_dir: observe(event, evt, session_dir) @@ -477,18 +773,21 @@ def observe(_event, evt, session_dir): session_dir = ensure_session(SessionId("s-both")) for index in range(2): if index: - discovery_client.results["second"] = tmp_path / "later.jsonl" + register_transcript("s-both", thread_id="third") _, background = dispatch_event( tmp_path, Event.Stop, {"session_id": "s-both", "transcript_path": str(tmp_path / "main.jsonl")}, session_dir=session_dir, - transcript_loader=lambda _: Session(()), + transcript_loader=loader, ) background() - initial = (tmp_path / "first.jsonl", tmp_path / "second.jsonl") - updated = (tmp_path / "first.jsonl", tmp_path / "later.jsonl") - assert seen == [initial, initial, updated, updated] - assert len(discovery_client.requests) == 2 - assert discovery_client.released == [] + assert seen == [ + ("first", "second"), + ("first", "second"), + ("first", "second", "third"), + ("first", "second", "third"), + ] + assert len(reads) == 2 + assert len(loads) == 2 diff --git a/tests/test_worker_graph_warming.py b/tests/test_worker_graph_warming.py new file mode 100644 index 00000000..4b39c59a --- /dev/null +++ b/tests/test_worker_graph_warming.py @@ -0,0 +1,388 @@ +import io +import threading +import time +from pathlib import Path + +import pytest + +from captain_hook.snapshots.client import ( + EvidenceIncomplete, + GraphSources, + RegisteredWarmState, + RootWarmState, + SnapshotClient, +) +from captain_hook.worker.protocol import EventResponse +from captain_hook.worker.service import WarmJob, WorkerService + + +def service(): + return WorkerService(io.BytesIO(), io.BytesIO(), dispatch=lambda _: (EventResponse(), None)) + + +def close(service): + with service._warm_guard: + service._warm_stop.set() + service._close_snapshots() + service._warm_executor.shutdown(wait=True) + service._executor.shutdown() + service._background.shutdown() + service._cleanup.shutdown() + + +def client(): + result = SnapshotClient(lambda _: {}) + result.bind_tool_registry({}) + return result + + +def wait_until(predicate): + deadline = time.monotonic() + 2 + while not predicate() and time.monotonic() < deadline: + time.sleep(0.005) + assert predicate() + + +def test_repeated_events_share_one_active_warmer(monkeypatch): + import captain_hook.worker.service as module + + worker = service() + started = threading.Event() + finish = threading.Event() + calls = [] + + def step(job): + calls.append(job.state.sources.thread_ids) + started.set() + assert finish.wait(2) + return True + + monkeypatch.setattr(module, "WARM_INTERVAL_SECONDS", 0) + monkeypatch.setattr(worker, "_warm_step", step) + sources = GraphSources(thread_ids=("one",), session_key="session") + try: + worker.schedule_graph_warm(client(), sources) + assert started.wait(2) + for _ in range(20): + worker.schedule_graph_warm(client(), sources) + finish.set() + wait_until(lambda: not worker._warm_jobs) + assert calls == [("one",)] + finally: + finish.set() + close(worker) + + +def test_new_membership_supersedes_active_warmer(monkeypatch): + import captain_hook.worker.service as module + + worker = service() + started = threading.Event() + finish = threading.Event() + calls = [] + + def step(job): + calls.append(job.state.sources.thread_ids) + if job.state.sources.thread_ids == ("old",): + started.set() + assert finish.wait(2) + return True + + monkeypatch.setattr(module, "WARM_INTERVAL_SECONDS", 0) + monkeypatch.setattr(worker, "_warm_step", step) + try: + worker.schedule_graph_warm(client(), GraphSources(thread_ids=("old",), session_key="session")) + assert started.wait(2) + worker.schedule_graph_warm(client(), GraphSources(thread_ids=("new",), session_key="session")) + finish.set() + wait_until(lambda: not worker._warm_jobs) + assert calls == [("old",), ("new",)] + finally: + finish.set() + close(worker) + + +def test_full_warm_queue_admits_new_owner_and_evicted_owner_can_return(monkeypatch): + import captain_hook.worker.service as module + + worker = service() + started = threading.Event() + finish = threading.Event() + served = [] + + def step(job): + served.append(job.owner_key) + if job.owner_key == "owner-0": + started.set() + assert finish.wait(2) + return True + + monkeypatch.setattr(module, "WARM_INTERVAL_SECONDS", 0) + monkeypatch.setattr(worker, "_warm_step", step) + source_client = client() + try: + worker.schedule_graph_warm(source_client, GraphSources(thread_ids=("0",), session_key="owner-0")) + assert started.wait(2) + for index in range(1, 33): + worker.schedule_graph_warm( + source_client, + GraphSources(thread_ids=(str(index),), session_key=f"owner-{index}"), + ) + assert len(worker._warm_jobs) == module.MAX_WARM_JOBS + finish.set() + wait_until(lambda: not worker._warm_jobs) + assert "owner-32" in served + assert "owner-31" not in served + worker.schedule_graph_warm(source_client, GraphSources(thread_ids=("31",), session_key="owner-31")) + wait_until(lambda: "owner-31" in served) + finally: + finish.set() + close(worker) + + +def test_warm_step_has_small_independent_budget_and_progress(): + calls = [] + + class Client: + def call(self, operation, **arguments): + calls.append((operation, arguments)) + return { + "status": "ok", + "usage": {"source_bytes_read": 1, "discovery_entries_examined": 0}, + "data": { + "kind": "warmed_registry", + "owner_epoch": "owner", + "membership_revision": "revision", + "next_index": 3, + "complete": False, + "source_offset": 0, + "source_size": 0, + "fact_cache_bytes": 100, + "fact_cache_write_bytes": 200, + "fact_cache_writes": 2, + }, + } + + worker = service() + job = WarmJob( + "session", + "fingerprint", + RegisteredWarmState(GraphSources(thread_ids=("one", "two", "three"), session_key="session"), Client()), + ) + try: + started = int(time.time() * 1000) + assert worker._warm_step(job) is False + operation, arguments = calls[0] + assert operation == "warm_registered" + assert arguments["limits"]["max_read_bytes"] == 8 * 1024 * 1024 + assert started < arguments["deadline_unix_ms"] <= started + 3000 + assert job.state.start_index == 3 + assert job.state.fact_cache_write_bytes == 200 + assert worker._warm_step(job) is False + assert calls[1][1]["start_index"] == 3 + assert calls[1][1]["membership_revision"] == "revision" + finally: + close(worker) + + +def test_warmer_uses_the_foreground_tool_registry(monkeypatch): + worker = service() + source_client = SnapshotClient(lambda _: {}) + source_client.bind_tool_registry({"custom_write": ("Write", {"file": "path"})}) + observed = [] + + def step(job): + observed.append(job.state.client.tool_registry()) + return True + + monkeypatch.setattr(worker, "_warm_step", step) + try: + worker.schedule_graph_warm(source_client, GraphSources(thread_ids=("one",), session_key="session")) + wait_until(lambda: not worker._warm_jobs) + assert observed == [source_client.tool_registry()] + finally: + close(worker) + + +def test_root_and_registered_warming_share_one_paced_fair_worker(monkeypatch, tmp_path): + import captain_hook.worker.service as module + + worker = service() + observed = [] + started = threading.Event() + advance = threading.Event() + + def step(job): + observed.append(type(job.state).__name__) + if len(observed) == 1: + started.set() + assert advance.wait(2) + job.state.last_usage = {"source_bytes_read": 0} + return observed.count(type(job.state).__name__) == 2 + + monkeypatch.setattr(module, "WARM_INTERVAL_SECONDS", 0) + monkeypatch.setattr(worker, "_warm_step", step) + source_client = client() + try: + worker.schedule_root_warm(source_client, tmp_path / "root.jsonl", {"id": "native", "version": "1"}) + assert started.wait(2) + worker.schedule_graph_warm(source_client, GraphSources(thread_ids=("one",), session_key="session")) + advance.set() + wait_until(lambda: not worker._warm_jobs) + assert observed == ["RootWarmState", "RegisteredWarmState", "RootWarmState", "RegisteredWarmState"] + finally: + advance.set() + close(worker) + + +def test_root_warmer_advances_bounded_source_without_restarting(): + calls = [] + offsets = iter((8 * 1024 * 1024, 16 * 1024 * 1024, 16 * 1024 * 1024)) + + class Client: + def call(self, operation, **arguments): + calls.append((operation, arguments)) + offset = next(offsets) + return { + "status": "ok", + "usage": {"source_bytes_read": 8 * 1024 * 1024 if offset < 16 * 1024 * 1024 else 0}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": offset, + "source_size": 16 * 1024 * 1024, + "complete": len(calls) == 3, + "facts_complete": len(calls) == 3, + }, + } + + state = RootWarmState(Path("/tmp/root.jsonl"), {"id": "native", "version": "1"}, Client()) + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is True + assert all(operation == "warm_root" for operation, _ in calls) + assert all(arguments["limits"]["max_read_bytes"] == 8 * 1024 * 1024 for _, arguments in calls) + assert state.stalled_steps == 0 + + +def test_root_warmer_requires_finished_facts_after_bytes_are_complete(): + class Client: + def call(self, operation, **arguments): + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "events_parsed": 0}, + "data": { + "kind": "warmed_root", + "owner_epoch": "owner", + "source_revision": "revision", + "source_offset": 202 * 1024 * 1024, + "source_size": 202 * 1024 * 1024, + "complete": True, + "facts_complete": False, + }, + } + + state = RootWarmState(Path("/tmp/root.jsonl"), {"id": "native", "version": "1"}, Client()) + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + with pytest.raises(EvidenceIncomplete, match="made no progress"): + state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) + + +def test_changed_membership_resets_warming_progress(): + calls = [] + + class Client: + def call(self, operation, **arguments): + calls.append(arguments) + return { + "status": "changed", + "reason": "membership changed", + "usage": {"source_bytes_read": 0, "discovery_entries_examined": 0}, + } + + state = RegisteredWarmState(GraphSources(thread_ids=("one",)), Client()) + state.start_index = 4 + state.membership_revision = "old" + + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert calls[0]["membership_revision"] == "old" + assert state.start_index == 0 + assert state.membership_revision is None + + +def test_warmer_stops_after_two_steps_without_native_progress(): + class Client: + def call(self, operation, **arguments): + return { + "status": "incomplete", + "reason": "source unavailable", + "usage": {"source_bytes_read": 0, "discovery_entries_examined": 0}, + } + + state = RegisteredWarmState(GraphSources(thread_ids=("one",)), Client()) + + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + with pytest.raises(EvidenceIncomplete, match="made no progress"): + state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) + + +def test_source_offset_proves_progress_despite_a_fixed_member_index(): + offsets = iter((8 * 1024 * 1024, 16 * 1024 * 1024)) + + class Client: + def call(self, operation, **arguments): + return { + "status": "ok", + "usage": {"source_bytes_read": 128, "discovery_entries_examined": 0}, + "data": { + "kind": "warmed_registry", + "owner_epoch": "owner", + "membership_revision": "revision", + "next_index": 0, + "complete": False, + "source_offset": next(offsets), + "source_size": 40 * 1024 * 1024, + "fact_cache_bytes": 0, + "fact_cache_write_bytes": 0, + "fact_cache_writes": 0, + }, + } + + state = RegisteredWarmState(GraphSources(thread_ids=("large",)), Client()) + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.stalled_steps == 0 + assert state.source_offset == 16 * 1024 * 1024 + + +def test_parsed_events_prove_progress_after_source_bytes_are_loaded(): + calls = 0 + + class Client: + def call(self, operation, **arguments): + nonlocal calls + calls += 1 + return { + "status": "ok", + "usage": {"source_bytes_read": 0, "events_parsed": 128}, + "data": { + "kind": "warmed_registry", + "owner_epoch": "owner", + "membership_revision": "revision", + "next_index": 0, + "complete": False, + "source_offset": 8 * 1024 * 1024, + "source_size": 8 * 1024 * 1024, + "fact_cache_bytes": 0, + "fact_cache_write_bytes": 0, + "fact_cache_writes": 0, + }, + } + + state = RegisteredWarmState(GraphSources(thread_ids=("large",)), Client()) + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.step(read_bytes=8 * 1024 * 1024, deadline_seconds=3) is False + assert state.stalled_steps == 0 + assert calls == 2 diff --git a/tests/test_worker_runtime.py b/tests/test_worker_runtime.py index fd059516..6b4f5a86 100644 --- a/tests/test_worker_runtime.py +++ b/tests/test_worker_runtime.py @@ -13,7 +13,7 @@ from captain_hook import app from captain_hook.daemon.context import ContextIO, bound_buffers -from captain_hook.snapshots.client import AttachmentLimit, EvidenceIncomplete +from captain_hook.snapshots.client import AttachmentLimit, EvidenceIncomplete, GraphEvidenceExpired from captain_hook.util import reqenv from captain_hook.worker.protocol import EventRequest from captain_hook.worker.runtime import ProductRuntime @@ -255,19 +255,39 @@ def fail(*_: object, **__: object) -> tuple[None, object]: assert response.stderr == "" -def test_other_incomplete_evidence_remains_visible() -> None: +@pytest.mark.parametrize( + "status", ["incomplete", "source_limit", "entry_limit", "output_limit", "deadline", "cancelled", "changed"] +) +def test_bounded_graph_evidence_fails_open_without_traceback(status: str) -> None: def fail(*_: object, **__: object) -> tuple[None, object]: - raise EvidenceIncomplete("output_limit", "evidence exceeds output bound") + raise EvidenceIncomplete(status, "graph budget exhausted") runtime = ProductRuntime( registry_factory=lambda _: FakeRegistry(), dispatcher=fail, install_writer=False, nlp_warmer=lambda: None ) - response, after = runtime.dispatch(request()) + response, after = runtime.dispatch(request(event="Stop")) + + assert after is None + assert response.status == "ok" + assert response.exit == 0 + assert response.stdout == "" + assert response.stderr == "" + + +@pytest.mark.parametrize("status", ["invalid_request", "parse_error", "permission_denied", "stale_handle"]) +def test_invalid_evidence_remains_visible(status: str) -> None: + def fail(*_: object, **__: object) -> tuple[None, object]: + raise EvidenceIncomplete(status, "invalid transcript evidence") + + runtime = ProductRuntime( + registry_factory=lambda _: FakeRegistry(), dispatcher=fail, install_writer=False, nlp_warmer=lambda: None + ) + response, after = runtime.dispatch(request(event="Stop")) assert after is None assert response.status == "error" assert response.exit == 1 - assert "evidence exceeds output bound" in response.stderr + assert "invalid transcript evidence" in response.stderr def test_background_snapshot_capacity_failure_does_not_fail_worker() -> None: @@ -287,6 +307,26 @@ def fail() -> None: after() + + +@pytest.mark.parametrize("status", ["stale_handle", "stale_cursor"]) +def test_expired_graph_evidence_fails_open_after_reply(status: str) -> None: + def fail() -> None: + raise GraphEvidenceExpired(status, "prepared graph expired") + + runtime = ProductRuntime( + registry_factory=lambda _: FakeRegistry(), + dispatcher=lambda *_, **__: (None, fail), + install_writer=False, + nlp_warmer=lambda: None, + ) + response, after = runtime.dispatch(request(event="Stop")) + + assert response.exit == 0 + assert after is not None + after() + + def test_hook_writes_are_captured_inside_the_product_response() -> None: def dispatch(*_: object, **__: object) -> tuple[None, object]: print("hook stdout") diff --git a/tests/test_worker_snapshot_transport.py b/tests/test_worker_snapshot_transport.py index 6ef6c956..157cd374 100644 --- a/tests/test_worker_snapshot_transport.py +++ b/tests/test_worker_snapshot_transport.py @@ -7,9 +7,9 @@ import pytest -from captain_hook.snapshots.client import CURRENT_CLIENT, EvidenceIncomplete +from captain_hook.snapshots.client import CURRENT_CLIENT, EvidenceIncomplete, Lease from captain_hook.snapshots.validation import validator -from captain_hook.snapshots.worker import empty_usage +from captain_hook.snapshots.worker import empty_usage, failure from captain_hook.worker.protocol import ( MAX_SNAPSHOT_FRAME, EventResponse, @@ -20,6 +20,7 @@ ) from captain_hook.worker.runtime import _run_detached from captain_hook.worker.service import WorkerService +from tests.test_snapshot_client import description from tests.test_worker_protocol import event @@ -57,14 +58,14 @@ def transport(): peers = [] runners = [] - def start(dispatch): + def start(dispatch, *, max_workers=16): worker, host = socket.socketpair() worker_input = worker.makefile("rb", buffering=0) worker_output = worker.makefile("wb", buffering=0) host.settimeout(3) host_input = host.makefile("rb", buffering=0) host_output = host.makefile("wb", buffering=0) - service = WorkerService(worker_input, worker_output, dispatch=dispatch) + service = WorkerService(worker_input, worker_output, dispatch=dispatch, max_workers=max_workers) completed = Future() def run(): @@ -126,6 +127,35 @@ def background(): assert service._snapshot_pending == {} +def test_foreground_release_waits_until_after_hook_result(transport): + def dispatch(request): + client = CURRENT_CLIENT.get() + client.bind_tool_registry({}) + lease = Lease(client, description()) + lease.release() + return EventResponse(), None + + _, _, incoming, outgoing, _ = transport(dispatch, max_workers=1) + write_message(outgoing, event(42)) + result = read_message(incoming) + assert result["op"] == "result" + cleanup = read_message(incoming) + assert cleanup["op"] == "snapshot_request" + assert cleanup.get("parent_id", 0) == 0 + assert cleanup["snapshot"]["request"]["operation"] == "release" + write_message(outgoing, event(43)) + second_result = read_message(incoming) + assert second_result["op"] == "result" + second_cleanup = read_message(incoming) + assert second_cleanup["snapshot"]["request"]["operation"] == "release" + reply = snapshot_reply(cleanup) + reply["snapshot"]["response"]["data"] = {"kind": "released", "released": True} + write_message(outgoing, reply) + second_reply = snapshot_reply(second_cleanup) + second_reply["snapshot"]["response"]["data"] = {"kind": "released", "released": True} + write_message(outgoing, second_reply) + + def test_eof_fails_reverse_waiter_before_draining_dispatch(transport): finished = threading.Event() @@ -173,6 +203,36 @@ def dispatch(request): assert read_message(incoming)["op"] == "result" +def test_foreground_transport_deadline_overrides_long_native_deadline(transport): + def dispatch(request): + value = { + "schema": "captain.transcript/1", + "request": { + "schema": "cc-transcript.snapshot/1", + "id": "root", + "operation": "stats", + "deadline_unix_ms": int(time.time() * 1000) + 120_000, + }, + } + with pytest.raises(EvidenceIncomplete, match="deadline elapsed"): + service.snapshot_exchange(request.id, value, expires_unix_ms=int(time.time() * 1000) + 80) + return EventResponse(), None + + service, _, incoming, outgoing, _ = transport(dispatch) + write_message(outgoing, event(8)) + snapshot = read_message(incoming) + assert snapshot["op"] == "snapshot_request" + assert snapshot["snapshot"]["request"]["deadline_unix_ms"] > int(time.time() * 1000) + 60_000 + assert read_message(incoming) == { + "protocol": 1, + "op": "snapshot_cancel", + "id": snapshot["id"], + "parent_id": 8, + } + write_message(outgoing, snapshot_reply(snapshot, error="cancelled")) + assert read_message(incoming)["op"] == "result" + + def test_snapshot_reply_requires_matching_parent(): service = WorkerService(io.BytesIO(), io.BytesIO(), dispatch=lambda _: (EventResponse(), None)) pending = Future() @@ -301,3 +361,78 @@ def test_cleanup_bypass_is_release_only_and_has_its_own_deadline(monkeypatch): service._close_snapshots() service._executor.shutdown() service._background.shutdown() + + +def test_failed_foreground_release_retries_after_reply_before_background_end(transport): + from captain_hook.snapshots import client as snapshot_client + + def dispatch(request): + client = CURRENT_CLIENT.get() + client.bind_tool_registry({}) + exchange = client._cleanup_exchange + client._cleanup_exchange = lambda wrapper: { + "schema": "captain.transcript/1", + "response": failure(wrapper["request"]["id"], "retained_limit", "busy"), + } + cleanup_seconds = snapshot_client.CLEANUP_SECONDS + snapshot_client.CLEANUP_SECONDS = 0 + try: + lease = Lease( + client, + { + "handle": {"owner_epoch": "owner", "lease_id": "lease"}, + "lease_expires_unix_ms": 9_000_000_000_000_000, + }, + ) + lease.release() + finally: + snapshot_client.CLEANUP_SECONDS = cleanup_seconds + client._cleanup_exchange = exchange + assert not lease.released + return EventResponse(), lambda: None + + _, _, incoming, outgoing, _ = transport(dispatch) + write_message(outgoing, event(42)) + assert read_message(incoming) == {"protocol": 1, "op": "background_begin", "id": 42} + assert read_message(incoming)["op"] == "result" + release = read_message(incoming) + assert release["op"] == "snapshot_request" + assert release.get("parent_id", 0) == 0 + assert release["snapshot"]["request"]["operation"] == "release" + assert release["snapshot"]["request"]["token"] == "lease" + write_message(outgoing, snapshot_reply(release)) + assert read_message(incoming) == {"protocol": 1, "op": "background_end", "id": 42} + + +def test_failed_cleanup_does_not_replace_the_original_reply(transport): + def dispatch(request): + client = CURRENT_CLIENT.get() + client.bind_tool_registry({}) + lease = Lease( + client, + {"handle": {"owner_epoch": "owner", "lease_id": "lease"}, "lease_expires_unix_ms": 9_000_000_000_000_000}, + ) + lease.cleanup_pending = True + return EventResponse(status="error", stderr="original failure\n", exit=1), None + + _, _, incoming, outgoing, _ = transport(dispatch) + write_message(outgoing, event(42)) + result = read_message(incoming) + assert result["op"] == "result" + assert result["response"]["stderr"] == "original failure\n" + cleanup = read_message(incoming) + assert cleanup["op"] == "snapshot_request" + assert cleanup.get("parent_id", 0) == 0 + request = cleanup["snapshot"]["request"] + write_message( + outgoing, + { + "protocol": 1, + "op": "snapshot_result", + "id": cleanup["id"], + "snapshot": { + "schema": "captain.transcript/1", + "response": failure(request["id"], "invalid_request", "bad token"), + }, + }, + ) diff --git a/uv.lock b/uv.lock index 138958e6..faf56d12 100644 --- a/uv.lock +++ b/uv.lock @@ -277,7 +277,7 @@ docs = [ requires-dist = [ { name = "ast-grep-py", specifier = ">=0.44,<0.45" }, { name = "capt-hook", marker = "extra == 'dev'" }, - { name = "cc-transcript", extras = ["judge"], specifier = "==14.28.1" }, + { name = "cc-transcript", extras = ["judge"], specifier = "==14.28.3" }, { name = "click", specifier = ">=8" }, { name = "filelock", specifier = ">=3" }, { name = "funcy", specifier = ">=2.0" }, @@ -320,17 +320,17 @@ wheels = [ [[package]] name = "cc-transcript" -version = "14.28.1" +version = "14.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "orjson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/13/b57edcdf91f3bb34d38a0245907d5504e35261737a586d2ee70409d0a3c8/cc_transcript-14.28.1.tar.gz", hash = "sha256:220f30d68eaafb9027463d288be1d79f1f63db33748887cb6b0d7bd8c8efaeaf", size = 12378013, upload-time = "2026-09-26T01:42:37.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/69/2b2566e08bd4f7d708959b6888c82e6147ee194292403c4f4ec391df3f36/cc_transcript-14.28.3.tar.gz", hash = "sha256:8dd732a3dfbcf1fe561f4befa5e7edd4538f3610f2ee9f849bbfd306998826eb", size = 12418056, upload-time = "2026-09-26T06:27:01.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/72/8f80d49ffc7dddae2583e872770c2f3b2850761d8e55785f3ac9df33a2ff/cc_transcript-14.28.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbad941cc4f59aa328987f1d69276a8b60463ce8b4858df213d2e621424f24bc", size = 29872647, upload-time = "2026-09-26T01:42:27.121Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c4/d4fc91d34874adf4125b3bbe54c561c5938eda356ef5cd6dae0464ea86cf/cc_transcript-14.28.1-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:f0ed0333aa48e3a883a8a6db807cc95200f72ad5f216e15e7d2b0468977f6c4b", size = 30413639, upload-time = "2026-09-26T01:42:30.003Z" }, - { url = "https://files.pythonhosted.org/packages/47/7e/031dfe5c2d1cc9580a3df53b0c6c2f8b57d8c5cb2a158ce9138e3d1008d1/cc_transcript-14.28.1-cp313-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b6faeb98aea0826d2c87f14604914f1a4024f8133a257b87d36bbba07cc272", size = 30064168, upload-time = "2026-09-26T01:42:32.48Z" }, - { url = "https://files.pythonhosted.org/packages/f7/85/739422546f6d0efeeb04fd691d6845a4ca5a95ded8954169bba55d0be72e/cc_transcript-14.28.1-cp313-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ec64d4c25f003c0045e83c8716ce0a3e8c6ee7bef8a31913aabf57b5bf7022e8", size = 30659462, upload-time = "2026-09-26T01:42:35.083Z" }, + { url = "https://files.pythonhosted.org/packages/ab/45/5b47bdfde6d0ae98bfbf70c12361216ed795556564a626b463734951b0ea/cc_transcript-14.28.3-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:af880d2591007abf6196e03476997f72d899264ffe2e5177e61072dc85b96a7b", size = 30028505, upload-time = "2026-09-26T06:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b0/6d12a5e553a44e98c221fd5e2982487e1a561cddc0a57f1b927559bc0c4f/cc_transcript-14.28.3-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:244a4b6e96b2965d325816e19610d481e2e93001e95bfd96f04ffadbbd447b4c", size = 30583826, upload-time = "2026-09-26T06:26:52.471Z" }, + { url = "https://files.pythonhosted.org/packages/ec/12/926be964e4add37e836703a9f8789784f956f103f86bcd7bf801918229f6/cc_transcript-14.28.3-cp313-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6bec695dbb860f0bb4bea3135f931917ae6a7d83fa228c1ec1d0bcf76a078f52", size = 30219280, upload-time = "2026-09-26T06:26:55.632Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/6532e53c85f905e5695c8bfc24bbf3e80962216dc36188f6f469d53dbed9/cc_transcript-14.28.3-cp313-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2999149d8145bd594db9486fa474768d901f4403225bcfb3feb5fd82e36b0543", size = 30833070, upload-time = "2026-09-26T06:26:58.772Z" }, ] [package.optional-dependencies]