Skip to content

refactor(mcp): serve hook runtime through its owners - #2362

Merged
ScriptedAlchemy merged 14 commits into
masterfrom
fleet/hook-runtime-owner
Sep 27, 2026
Merged

ScriptedAlchemy merged 14 commits into
masterfrom
fleet/hook-runtime-owner

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

tracedecay_hook_runtime, the internal tool every agent-host hook calls, is now a typed owner operation (ApplicationSurfaceOperation::HookRuntime, wire name unchanged). The project's graph-tool owner answers project hooks. Hooks with no project route go to the daemon's profile owner, through the is_profile_owner_request generalization from #2351. It is listed in GRAPH_TOOL_OPERATIONS and INTERNAL_OPERATIONS, so it is served by name and never advertised: tools/list stays at 230.

Typed request, result, and effect

  • Request. HookRuntimeSurfaceRequestV1 is tagged by action with deny_unknown_fields. It takes exactly what the shipped hosts send (derived from hooks/mod.rs, daemon_ports.rs, dispatch.rs, codex.rs, claude.rs, cursor_compact.rs, and the Hermes sync_turn): reset_counter, hook_v2_admit, hook_v2_delivery_receipt, hook_v2_feedback_notice_delivery, opencode_lsp_updated, ingest_transcript, codex_compact, claude_compact, cursor_compact, user_review, hermes_receipt, and hook_v2_profile_admit. Hook-native payloads (envelope, receipt, event, messages) stay JSON in the contract and are decoded by the hook domain types that own them.
  • Result. HookRuntimeResultV1 is tagged by action. Each action has its typed outcome, with per-status variants for admissions and notice delivery and one compaction outcome per field set. It serializes to the JSON the handler emitted, with one deliberate change: the three outputs that lacked action now carry it (hook_v2_delivery_receipt, the bound hook_v2_feedback_notice_delivery, and the skipped cursor_compact). I checked every host decoder; none rejects the extra key. The hand-written DaemonAdmissionResponseWireV1 in daemon_ports.rs is gone; the host decodes the contract type. Every host sender now builds the typed request, so a sender cannot drift from what the daemon accepts.
  • Effect. New EffectClass::RecordsHostEvidence: it records a host's session evidence (transcripts, hook admissions, receipts) in daemon-owned session stores. Owner entry: 120 s ceiling (unchanged from today's interactive ceiling), RunEach, not interruptible.

Refused now, where they used to be ignored or defaulted:

  • unknown keys, e.g. project_root, and timeout_budget_ms, which the Cursor/Kiro/Pi senders sent and the daemon ignored (the senders no longer send it);
  • provider and session_id on compaction (removed from the Codex and Claude senders);
  • Hermes' storage_scope on hook ingest (Hermes now routes on user_scope);
  • a missing user_scope;
  • nine actions no shipped host or bundle sends: accounting_receipt, hook_v2_guidance_lookup, hook_v2_scout_prepare, hook_v2_feedback, hook_v2_cancel, hook_v2_status, the four hook_v2_scout_* reads, and codex_stop. Their handlers are deleted.

What moved with the tool

  • The live-transcript refresh join and the transcript-source profile scope used to run only in the MCP complete_tool_call path, keyed by tool name. They now run in the owner, so the CLI (tracedecay tool hook_runtime, which Hermes uses) gets them too. live_transcript_refresh.rs keeps only the lcm_preflight rule and gains join_hook_ingest_refresh.
  • The owner's session authorities now carry the profile identity, background CPU, and project LCM authority that the MCP dispatch path gave the old handler.
  • A daemon invocation for hook_runtime requires the RegisteredHostIngest server, not Core, just as the MCP connection route already did. Both use one hook_runtime_requirement, built on the contract's hook_runtime_needs_session_stores (every action except reset_counter).
  • Ownership window. Project open publishes the full server before it re-registers as the graph-tool owner, so for a moment the registered owner is still the core server, which has no session stores. A hook call in that window got project_authority_unbound (seen in packaged_host_ingest_delivers_a_registered_advisory_cycle after the refactor(mcp): serve admin cli through its owners #2354 merge). The owner port now answers application.runtime.mounting there. Both the CLI loop and call_default_tool already re-send on that refusal.
  • Owner refusals keep the hook error's reason code and retry verdict (graph_tool_error_problem). Before, they fell through to graph_tool.failed.
  • The root hook transport (runtime_ports::daemon_tool_json) reads an isError result whose payload is a problem envelope as the owner's typed error, so hooks still see failures as Err.
  • Hermes fix. sync_turn now requests format: json. Without it, this internal tool answered markdown, call_tracedecay_json reported "invalid nested JSON", and the turn-completed/ingested receipts never fired. The same happens on master.

Deleted

  • dispatch_admin_tools' hook_runtime arm; hook_runtime's INTERNAL_DAEMON_TOOL_NAMES entry and its BINDING_GROUPS Admin-row name.
  • internal_daemon_tool_definition (its only entry), its CLI use, and its test.
  • The hand-written projectless handler, projectless_hook_runtime_response, and hook_runtime's projectless_tool_is_discoverable entry and dispatch arm.
  • The tool_errors::tool_error_response hook_runtime branch; structured_hook_error_data is now test-only.
  • handlers/hook_runtime/terminal.rs (codex_stop), the dead scout actions, accounting_receipt, the stub run_user_review, and its unreachable review tail.
  • hook_runtime_behavior_tests.rs. It drove a standalone server with no owner route; its behavior is rewritten as the mcp_suite production test below.
  • With admin_sync, admin_project (refactor(mcp): serve admin project through its owners #2351), admin_cli (refactor(mcp): serve admin cli through its owners #2354), and now hook_runtime all moved, the Admin dispatch group has no tools left. McpToolDispatchGroup::Admin, dispatch_admin_tools, their binding row and arms, and the projectless connection's now-unread client_identity field are deleted. I left LegacyToolCompatibilityOwner, resolve_catalog_tool_binding, dispatch_compatibility_tool, and the generic fallback for the coordinator.

git diff --stat origin/master...HEAD: 76 files, +2198/−2297. The handler, projectless, catalog-definition, error, refresh, host-port, binding, and dispatch-group files alone are +838/−1746.

Fail-before / pass-after

Test: mcp_suite mcp_handler_test::hook_runtime_request_test::hook_runtime_answers_typed_results_and_refuses_what_no_host_sends, over MCP tools/call on the production composition fixture.

On origin/master source (6ae6461) with only this test added:

test mcp_handler_test::hook_runtime_request_test::hook_runtime_answers_typed_results_and_refuses_what_no_host_sends ... FAILED
assertion `left == right` failed: hook action must refuse: {"_meta":{"duration_us":3268},"content":[{"text":"**action:** reset_counter\n**reset:** true\n","type":"text"}]}
  left: Null
 right: true
test result: FAILED. 0 passed; 1 failed

Master ignored {"action":"reset_counter","project_root":"/elsewhere"} and reset the counter.

On this branch: ok. It asserts these literals:

  • {"action":"reset_counter","reset":true};
  • the literal claude_compact record;
  • typed invalid_request refusals for project_root ("unknown field project_root, there are no fields"), for timeout_budget_ms (with the full expected-field list), and for codex_stop ("unknown variant codex_stop, expected one of …");
  • profile-owner routing on a project connection: user_review answers "projectless Hermes review is unavailable: …", a user-scope ingest answers "missing required parameter session_id", and a malformed Hermes event answers "invalid Hermes receipt event: missing field event".

The CLI proof is core_cli_suite user_scoped_transcript_ingest_handshakes_projectless_from_filesystem_root_cwd. It asserts that tracedecay tool hook_runtime from / handshakes projectless and sends a ProfileGraphTool { HookRuntime } invocation carrying the exact arguments.

Runtime journey

Setup: debug tracedecay from this branch; isolated HOME, TRACEDECAY_DATA_DIR, TRACEDECAY_GLOBAL_DB, and XDG_CONFIG_HOME under the worktree; one daemon under systemd-run --user --scope --unit=hook-runtime-owner-journey -p MemoryMax=6G -p MemorySwapMax=1G; tracedecay init of a scratch git project.

$ tracedecay tool --project proj hook_runtime --json --args {"action":"reset_counter"}
   **action:** reset_counter / **reset:** true                                  exit=0
$ tracedecay tool --project proj hook_runtime --json --args {"action":"reset_counter","project_root":"/elsewhere"}
   isError=true problem: {"kind":"invalid_request","code":"application.surface.invalid_request","message":"invalid arguments for tracedecay_hook_runtime: unknown field `project_root`, there are no fields"}   exit=1
$ ... {"action":"cursor_compact","event_json":"{\"conversation_id\":\"c1\",\"messages_to_compact\":0}"}
   action cursor_compact / reason no messages to compact / relation_projection_status not_applicable / status skipped   exit=0
$ ... {"action":"ingest_transcript","provider":"hermes","session_id":"journey-project","user_scope":false,"messages":[…]}
   status committed / messages_upserted 1 / observations_committed 1 / user_scope false   exit=0
$ ... {"action":"ingest_transcript","provider":"cursor","user_scope":false,"event_json":"{}","timeout_budget_ms":250}
   isError=true problem: invalid_request "unknown field `timeout_budget_ms`, expected one of `provider`, `user_scope`, `session_id`, `event_json`, `messages`, `max_new_bytes`"   exit=1
$ (cwd=/) tracedecay tool hook_runtime --json --args {"action":"ingest_transcript","provider":"hermes","session_id":"journey-user","user_scope":true,"format":"json","messages":[…]}
   {'action': 'ingest_transcript', 'admission': {'retryable': False, 'status': 'committed'}, 'completed': True, 'messages_upserted': 1, 'observations_committed': 1, 'provider': 'hermes', 'status': 'committed', 'user_scope': True}
$ (cwd=/) ... {"action":"user_review","provider":"codex","session_id":null}
   isError=true problem: invalid_request "projectless Hermes review is unavailable: automation requires a pinned project configuration"   exit=1
$ (cwd=/) ... {"action":"codex_stop","session_id":"s"}
   isError=true problem: {"kind":"invalid_request","code":"project_required", ...}   exit=1
$ message_search (user scope) "lighthouse rota" -> returned 1; message_search (project) "project quartz" -> returned 1

Real hook callbacks over the daemon MCP route (hook-claude-post-compact with and without a project cwd, hook-codex-stop, and hook-hermes-terminal-receipt) all exited 0 with {}. Their analytics recorded the daemon calls, and Hermes' native event was admitted by the profile owner ("disposition": {"class":"application","reason_code":"hook_v2_accepted"}). The daemon log's only refusal is the deliberate codex_stop call.

tracedecay serve over stdio:

tools/list: 230 tools
  tracedecay_hook_runtime advertised: False
tracedecay_hook_runtime {"action": "reset_counter", "format": "json"}: isError=None 3ms
     {"action":"reset_counter","reset":true}
tracedecay_hook_runtime {"action": "user_review", ...}: isError=True 5ms   structuredContent.problem.code="application.surface.invalid_request"
tracedecay_hook_runtime {"action": "hook_v2_status", "control": {}}: isError=True 3ms   "... unknown variant `hook_v2_status` ..."

The same journey was repeated on the final binary, after the #2354 merge and the ownership-window fix, with a fresh isolated profile:

{"action":"reset_counter","reset":true}
invalid_request: unknown field `project_root`, there are no fields
{"action":"ingest_transcript","admission":{"retryable":false,"status":"committed"},"completed":true,...,"messages_upserted":1,"status":"committed","user_scope":false}
(cwd=/) {"action":"ingest_transcript",...,"status":"committed","user_scope":true}
(cwd=/) {"action":"hermes_receipt","status":"recorded"}
analytics: hermes turnCompleted daemon_calls 2 hook_v2_accepted; claude PostCompact daemon_calls 1
tools/list: 230 tools; tracedecay_hook_runtime advertised: False; reset_counter by name -> {"action":"reset_counter","reset":true}
daemon.log WARN/ERROR lines: 0

systemctl --user stop hook-runtime-owner-journey.scope: inactive; no daemon of mine remains.

Checks

These counts come from after merging #2351 and #2354.

  • Lib: tool-catalog 7, contracts 428 (plus the new predicate test: hook_runtime_surface 4 of 4), mcp-catalog 28, tracedecay-mcp 380, daemon-protocol 65, daemon-service 322 (including adoption_observation with capability.application.primitive.hook-runtime), api 53, agent-hosts 485.
  • mcp_suite -- hook_runtime_request_test session_search_test schema_test protocol_test admin_test info_health_request_test: 61 passed, 1 failed. The failure, protocol_test::test_tools_call_search, saw staleness_state: verifying under load; it passed on rerun and in both earlier runs.
  • Root --lib -- mcp:: daemon::: 733 passed, 1 failed. The failure is runtime_identity::concurrent_same_identity_worktrees…, master-red (test: three suites still expect JSON-RPC errors for owner refusals now rendered as isError #2344).
    • An earlier run failed replay::client_identity_startup_replays_retained_profile_receipts, which asserted the old JSON-RPC error. It now asserts the owner's canonical_admission_failed refusal.
    • After the ownership-window fix, daemon::tests::bootstrap daemon::tests::replay daemon::projectless mcp::: 257 passed, 1 failed. The failure, routing::many_slow_initialize_roots_share_one_discovery_budget, took 3.011 s against its 3 s bound under shared-host load; it passed in both earlier root runs.
  • tracedecay-cli --bins: 334 passed. core_cli_suite: 144 passed, 2 failed. Both failures are tool_diagnostics_reads_the_typescript_producer_publication (master-red, test: three suites still expect JSON-RPC errors for owner refusals now rendered as isError #2344) and its sibling tool_diagnostics_reads_a_monorepo_package_finding, with the same host TypeScript producer cause (application.diagnostics.unavailable).
  • Hook suites:
    • hermes_suite lcm_bridge: 25 passed. The full hermes_suite passed 26 of 26 before the Hermes format fix.
    • transcript_ingest_suite: hermes:: 23 passed, plus projectless_hermes_turn_sync… and repeated_codex_compactions….
    • session_suite hook_ingest_join_runs_the_bound_refresh_worker: passed.
    • hooks_lsp_suite native_hook_captures_only_bound_transport_spool_records: passed.
    • runtime_acceptance_suite packaged_host_ingest_delivers_a_registered_advisory_cycle: 3 of 3 on the rebuilt binary.
  • scripts/hermes_plugin_unit_check.py: 45 of 45.
  • transport_acceptance_suite killed_daemon_retries… is not compiled in this configuration (0 matched), so it is not counted.
  • cargo clippy on every touched crate, --all-targets -D warnings, with and without tracedecay/test-transport,tracedecay/test-helpers: clean.
  • cargo fmt --all -- --check: clean. contracts:check: up to date.
  • After merging fix(daemon): report a busy configuration store as unavailable #2357 through fix(code-index): answer master-red reads from their true authority #2361:
    • mcp_suite hook_runtime_request_test session_search_test: 9 passed.
    • Libs: contracts 429, tracedecay-mcp 380, tool-catalog 7, agent-hosts 485, daemon-service 322.
    • Root daemon::tests::replay daemon::projectless daemon::tests::bootstrap mcp::tools::handlers: 152 passed.
    • Clippy with test features: clean.

Left open

  • The hook error's admission status no longer reaches the wire; its reason code and retry verdict do, and no host read the status. After this change, TraceDecayError::hook_runtime_status has only test readers.
  • A project-scope ingest still reports hint_outcomes: profile_root_unavailable. This predates the change: the project path has never passed a profile root, so hook hints never settle. Filed as hook ingest never settles hint outcomes: project route passes no profile root #2355.
  • handle_tool_call_with_registry_options refuses hermes_home for every tool before dispatch, as a JSON-RPC error. That guard belongs to the generic fallback, which the coordinator deletes.

…owner

# Conflicts:
#	crates/tracedecay/src/daemon/projectless.rs
…owner

# Conflicts:
#	crates/tracedecay-api/src/http/application_operation_owner.rs
#	crates/tracedecay-contracts/src/graph_tool.rs
#	crates/tracedecay-contracts/src/policy.rs
#	crates/tracedecay-contracts/src/retrieval/catalog.rs
#	crates/tracedecay-contracts/src/sdk_catalog.rs
#	crates/tracedecay-daemon-protocol/src/application_surface.rs
#	crates/tracedecay-daemon-protocol/src/application_surface/invocation.rs
#	crates/tracedecay-daemon-protocol/src/contract/mod.rs
#	crates/tracedecay-mcp/src/tools/binding.rs
#	crates/tracedecay-tool-catalog/src/manifest.rs
#	crates/tracedecay-tool-catalog/src/operation.rs
#	crates/tracedecay-tool-catalog/src/owner_side_effect.rs
#	crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs
#	dashboard/codegen/schemas/dashboard-contracts.schema.json
#	dashboard/src/contracts/generated.ts
#	sdks/typescript/src/operations.ts
…owner

# Conflicts:
#	crates/tracedecay-api/src/http/application_operation_owner.rs
#	crates/tracedecay-contracts/src/policy.rs
#	crates/tracedecay-contracts/src/retrieval/catalog.rs
#	crates/tracedecay-contracts/src/sdk_catalog.rs
#	crates/tracedecay-daemon-protocol/src/application_surface.rs
#	crates/tracedecay-daemon-protocol/src/contract/mod.rs
#	crates/tracedecay-mcp/src/tools/binding.rs
#	crates/tracedecay-tool-catalog/src/manifest.rs
#	crates/tracedecay-tool-catalog/src/operation.rs
#	crates/tracedecay/src/daemon/profile_owner.rs
#	crates/tracedecay/src/daemon/projectless.rs
#	crates/tracedecay/src/mcp/server/graph_tool_owner.rs
#	crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs
#	dashboard/codegen/schemas/dashboard-contracts.schema.json
#	dashboard/src/contracts/generated.ts
#	sdks/typescript/src/operations.ts
@changeset-bot

changeset-bot Bot commented Sep 27, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ad7d089

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ScriptedAlchemy
ScriptedAlchemy merged commit 6cc0511 into master Sep 27, 2026
1 check passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T22:41:53.106695Z ad7d089 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ScriptedAlchemy
ScriptedAlchemy deleted the fleet/hook-runtime-owner branch September 27, 2026 22:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad7d089240

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +64 to +68
Self::HookRuntime => Some(OwnerSideEffectEntryV1 {
effect: EffectClass::RecordsHostEvidence,
ceiling_millis: INTERACTIVE_CEILING_MILLIS,
identical_calls: IdenticalCallPolicyV1::RunEach,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat hook runtime calls as authoritative effects

When a hook call is cancelled or reaches its deadline after daemon admission, application_surface_cancellation_policy still classifies HookRuntime as ReadOnly, even though this entry makes it a non-cancellable owner side effect. The controlled client therefore returns Cancelled/TimedOut immediately instead of waiting for authoritative settlement, while the owner ignores cancellation and may finish committing the transcript, admission, or receipt; a host retry can then run the RunEach effect again. Add HookRuntime to the authoritative-effect policy (or derive that policy from this owner-side-effect metadata) so the transport behavior matches the published not_cancellable contract.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant