Run pinned SessionStart hooks before default model dispatch
Problem
LocalHarness retained hook configuration but did not run SessionStart hooks before default model dispatch. New root admissions must read current settings while queued sessions retain their exact pinned configuration. Hook output must remain bounded and process failures must retain their classification.
Design
Run matching SessionStart commands before provider dispatch in the admitted task's selected workspace. Merge returned context into the model pipeline. Retain exclusive dispatch claims and complete typed HookOutput values as immutable context files in the existing task volume; recovery reuses completed outcomes and never resends an uncertain effect.
Read a fresh settings snapshot when a new automatic manifest is admitted. The host retains settings locations and trust policy, replacing its compiled hook cache. An existing manifest or an explicit override is selected before this read. MCP configuration is excluded from the per-admission hook query. Startup selects only MCP settings roles, so malformed unused hook files cannot prevent recovery of a pinned session.
Partition the aggregate capture allowance across concurrent matching commands. Inspect borrowed JSON spans in fixed field slots, then reserve the retained delta before decoding strings/arguments or growing collections. Count only new fields and changed replacement input, without an encoded copy or rescanning prior outputs. Replacement arguments use canonical scalar sizes, including expanding numeric spellings; raw JSON string spans are safe upper bounds for retained text. Only a private Reservation can commit an owned delta.
Overflow enters an Exhausted state instead of returning from the loop. It keeps folding explicit deny/ask decisions and failure policies for every remaining command, while discarding optional context and verbose reasons. Its bounded OutputLimit outcome retains decision flags. Preflight the largest exhausted outcome before claiming or launching anything.
Decisions
- The exact manifest reference is protected in the admitted task volume before its model input is published. Hook execution authenticates this reference; ordinary file writers cannot replace it. If another producer admitted a task without this derived pointer, rebuild it from that owner's authenticated admission and pinned model command. Check the manifest digest before publication; a populated pointer does not read the model input again. There is one durable command contract and no version fallback.
- Lifecycle state remains files on the caller's StreamProvider and volume provider. The existing ToolExecutor workspace binding supplies execution; no store or transport is added.
- Static per-command capture quotas preserve concurrent dispatch and deterministic merging without adding a lock. Each quota is min(65,536, aggregate/count); a noisy hook can exhaust its own share even if peers emit less.
- ProcessError is one platform-independent cause-carrying enum shared by execution, HookDiagnostic retention and Error::Process propagation. Exhaustive message-ownership conversions preserve its variant, exit code and text. There is no string kind tag or reverse lookup. Explicit permission decisions remain separate; report failures emit structured warnings. Stop continues to ignore failure policy.
- The fold's apply method returns (), so a merge error cannot be propagated to abandon later guard decisions. Available and Exhausted states cover both successful reservation and overflow. Only accepted reservations allocate retained output; an exhausted fold never decodes an owned reason or replacement argument. Numeric canonicalization and Unicode validity are checked by the borrowed inspector. No synchronization was added.
- Blocking workers retain the selected workspace guard through cancellation and child-tree cleanup. Claim and outcome publications use the current task's owner fence and deadline.
- The concurrency qualification uses real task volumes against a bounded StreamProvider contract (one journal, two prepared contenders), the real sync shim, and failing negative controls. Quota and incremental accounting invariants use shrinking properties; they introduce no shared mutable state.
- The recovery fixture owns the project backend across reopen; task runtime, journal and manifest bindings are reconstructed. Settings and executable files are removed before recovery.
- This PR implements SessionStart. PreToolUse, PostToolUse and Stop dispatch follow separately.
Deletions
Remove the host-lifetime compiled hook cache and its eager bind-time load. Move normalized hook publication from LocalHarness settings glue into Hooks::publish. Delete the post-allocation budget charger, overflow early return, full JSON object materialization for output decisions, HookErrorKind/name table and reverse tag lookup. Repoint process error callers to the shared enum. Reuse the existing matcher, runner, process tree, shell selector and task-volume publication path.
Tests
Final signed head6886431d9679447289b408235d48190f5e98189c passed the focused normal-build follow-up: 50/50 native tests (budget12 .025s, output5 .022s, effects3 .017s, runner23 .029s, lifecycle5 .404s, future bounds2 .025s), full Windows GNU library/test cross-check (59.66s), ordinary stable Cargo compilation of the hook_output binary WITHOUT cfg(test)/cfg(fuzzing) (3m25), and the 13-seed corpus plus1,000 instrumented fuzz runs over all four event-specific folds (1.218s; build2m11). Native build took5m32. The extra constructor property preserves default256 cases/shrinking and catches an empty-ledger negative control. The fuzz target now uses only the same unconditional Budget constructor/merge API as production; normal qualification cannot depend on a test helper. There are101 distinct focused native passes across the batches below;50 affected cases were revalidated at this final head. Synchronization is unchanged from the two qualified real Loom models below.
At signed 2deebe4923dc4a68a5af5aa936a8818394bf853f, 100 focused native tests passed after one canonical build (6m21). Accounting11: 0.046s; typed failures/runner23: 0.072s; output properties5: 0.035s; effects3: 0.028s. The longest suite was lifecycle5 at 2.143s; every suite remained under 10s. Full Windows GNU library/test cross-check passed (4m11). Node qualification runner10 passed. Both real instrumented Loom models and negative controls passed (.02/.01s), and the checked-in 13-seed corpus plus 1,000 fuzz runs passed (.665s; build 5m48). Default 256-case shrinking coverage and all prior assertions remain enabled.
New properties and negative controls cover overflow before/after denial; all five PreToolUse guard forms after exhaustion; pre-reservation allocation (the old allocate-first variant is rejected by the same counting allocator); canonical numeric expansion (a raw-byte-charge variant fails the same file-bound detector); exact canonical-delta accounting and reset-ledger rejection; and all eight cause variants through retention, propagation and retention again (the former InvalidOutput-to-Io conversion is rejected). Checked-in fuzz seeds cover raw control-byte expansion, malformed surrogates, typed retained errors and expanding numeric collections. The production runner regression reproduces the two-report-hook 4KiB probe without spawning a process.
A broader existing local MCP HTTP scenario was also run and FAILS with Indeterminate after the server completes all five expected exchanges. Its final trace has one model request and one scripted response unused. It fails alone too; proxy variables are absent, and explicit loopback bypass does not change it. No production HTTP/RemoteTool/executor code changes in this review delta. A baseline failure is not claimed: that baseline was not rebuilt. The fixture keeps every assertion and now exposes request/server state on failure. The overall batch therefore exits nonzero at this final scenario; the 100 focused tests, Windows cross-check, models and fuzz stages above passed beforehand. This is recorded for follow-up rather than hidden or given a larger allowance.
Commands through bs.ps1:
cargo test --locked -p acyclic-harness --features filesystem-local,native-execution,terminal,mcp-http,test-support,grpc --lib --no-run; run its binary with focused hooks, native settings, MCP installation, context authority/configuration, future-size and native-manifest filters.cargo check --locked -p acyclic-harness --lib --tests --features filesystem-local,native-execution,terminal,mcp-http,test-support,grpc --target x86_64-pc-windows-gnucargo check --locked -p acyclic-harness-fuzz --bin hook_output(ordinary build, without special cfgs)node --test scripts/qualify-mcp-loom.test.mjsnode scripts/qualify-mcp-loom.mjs --hook-lifecyclecargo +nightly-2026-07-01 fuzz build hook_output --dev --target x86_64-unknown-linux-gnu --fuzz-dir rust/crates/harness-fuzz; execute its checked-in corpus plus-runs=1000 -max_len=4096.
Browser WASM test compilation was attempted separately and failed on existing native-only test imports and Send bounds; those are assigned to #825. Exact diagnostics are in Q:/lanes/gc-context-818-wasm-errors.txt. No unrelated WASM tests are changed here.
Net line delta against main: +3,223 (+3,751 / -528), 58 files; the latest review delta is +938 net.