perf(context-guard): skip the zone resolver when the snapshot is unchanged - #4193
Conversation
…ields without jq run-guards.sh sources its guards into its own shell instead of one command-substitution subshell per guard: `exit` is a dispatcher function that records the guard's status and runs the next guard from inside the call, stdout documents are collected through hook::emit_document, and a guard that dies of a hard error hands its status to the abort boundary's new chain slot (_GAB_CONTINUE), which runs the guards still owed in one subshell. Per-invocation analysis state (the alias memo) is reset before each guard, so a later guard's alias walk is not answered by an earlier guard's memo. lib/hook-utils.sh answers a well-formed payload's plain-string fields with its builtin JSON parser (hook::_fast_fields) and runs jq only for a shape it cannot prove; hook::jq_fields_uncached names the same body for the dispatcher's cache, hook::emit_document is the one stdout path, and hook::extract_bash_subject_to is the in-shell telemetry subject. The 17 carrying plugins take the synced copy with a patch bump. Measured on Windows 11 + Git Bash with an exact job-object census of the harness's own invocation: the Bash-lane chain went from 23 process creations to 3 and 880 ms to 285 ms isolated p50; the PowerShell lane from 100 to 80 creations and 3.3 s to 2.7 s. Decisions are byte-identical (rc, stdout, stderr) against 0.33.11 over the perf baseline's 17-command corpus in both tool modes, over 653 commands harvested from the guard suites on the Bash lane and 200 on the PowerShell lane, and over the Write, Edit and drive-root-tmp lanes. Handoff item: 20260913-034031 (program 20260915-153000). Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
hook::extract_bash_subject_to assigns SUBJECT through a nameref, which shellcheck 0.11 cannot follow; CI's lint lane failed SC2154 on the two guards that read it in emit_tel. Declaring the variable empty first is the idiom block-hook-bypass already uses. block-no-verify 256/0 and flag-commit-pr-skill-bypass 35/0 unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI's machine-specific-paths hygiene check refuses a Windows user path in a fixture. The value is opaque to the parser under test; hook-utils 504/0 unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…anged zone-crossing-inject.sh reads three files the world outside it can move: the per-session snapshot, the optional zones.json, and the compaction marker. When none is newer than the `.seen` mark left by the last COMPLETED resolve, the fire would repeat that resolve's decision, which is already persisted, so it exits before starting a process. The mark is stamped with a redirection and compared with `-nt`, both builtins, and it moves only after both markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried rather than skipped. The envelope parse had to become free for the skip to mean anything, and it is free only on the payloads hook::jq_fields can prove: within its ceiling the builtin parser answers both fields with no process, above it the single here-string jq stays, because the helper's oversize fallback reads through a process substitution and costs four process creations against that jq's two. That builtin parser arrives with the guardrails-run-guards-in-process-no-subs merge (PR #4185's lib/hook-utils.sh sync), which this fast path depends on. Process creations under a Windows job object, 5 reps, identical across reps; the subject's own floor is 3 (the `-c` shell, env, the shebang's shell): small envelope first 11 -> 9 repeat 9 -> 3 rewritten 9 -> 7 150 KB batch first 11 -> 11 repeat 9 -> 5 rewritten 9 -> 9 No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms -> 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen and its crossing waits for the next statusline render, so the window is the resolve rather than an mtime tick. A missed crossing is late, never lost, and a spurious one is impossible, because skipping only ever chooses silence. The suite gains the skip cases (silent repeat, byte-identical crossing after a skipped fire against a control session, a newer zones.json, a resolver failure leaving the mark untouched) and splits the per-batch budget in two: the steady fire now spawns nothing, and a resolving fire spawns the resolver alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…guard-zone-crossing-fast-path-on
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 57s —— View job Security review complete
No security issues found. This PR is a performance-only refactor of
|
|
Last security-reviewed head: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6fb54ea38
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 5m 56s —— View job Code review —
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…ty lint The shell-portability lint reads the backslash-w in the previous spelling as a GNU-only regex class. The value is opaque to the parser under test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both surfaces are declared purged in scripts/em-dash-purged-paths.txt, so the lint lane treats one as a regression. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r is removed The unchanged-input skip compared three inputs with `-nt` alone, which only ever sees an existing file becoming newer. Removing the zones.json override or the compaction marker moves no mtime, so both read as unchanged and the fire skipped, leaving the stale zone in place until some unrelated snapshot write. The mark now also carries one line recording whether each of the two optional inputs existed behind the last completed resolve, read back with the `read` builtin, and the skip is taken only when the three `-nt` tests are false and those flags still match the current `-e` results. A mark with no readable line, an older build's stamp or a write that failed after truncating, never takes the skip. Both flags are captured from what the resolve actually used, so an override created while the resolver runs is not recorded as seen. Every added read and write is a builtin, so both process budgets are unchanged: the small steady repeat fire still measures 3 process creations under a Windows job object, 5 reps identical, against the subject's own floor of 3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9748035 to
e2c053b
Compare
…ts key bound hook::_fast_fields indexes the payload's key strings with an associative array, which Bash added in 4.0, and it ran on every hook::jq_fields call. On the 3.2 shell macOS ships, and which these hooks document support for, `local -A` fails per call. hook::_fast_fields_supported is the predicate, split out the way hook::read_supports_nchars is so a test can force the below-floor branch on a modern host, and hook::jq_fields_uncached asks it before entering the fast path. Below the floor jq answers, unchanged. The index loop also skipped any string body longer than a fixed 60 bytes before decoding it, while nothing capped the key names a caller may ask for. Two wrong answers came out of that: a requested key longer than 60 characters was proven ABSENT while present, and a key of 11 or more characters spelled with \u escapes (hook_event_name is 15, 90 escaped) was missed the same way. The bound is now six times the longest requested key name, the width of `\uXXXX` per identifier character, which is the bound the header comment always described. The suite gains four cases: the below-floor branch forced with the fast path replaced by a tripwire, a present 70-character key, an absent one, and hook_event_name spelled entirely in \u escapes. Each compares the fast path against jq rather than against an expectation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The chain-slot paragraph asks a function plugged into _GAB_CONTINUE to be builtins only, never exit, and never touch the trap, and the slot's own comment says it must not return. run-guards.sh's consumer does none of that: run_guards::guard_died forks a subshell for the guards still owed, spawns jq to merge their documents, and ends at `builtin exit`. Say so. run-guards.sh is the one documented exception, and it is one because it is the dispatcher finishing the run the process owes rather than a hook doing exit-time work. The discipline is unchanged for everyone else, and the slot comment now matches it: a chained function that returns hands control back and the handler settles the status. Comment only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lliding plugins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-no-subs' into context-guard-zone-crossing-fast-path-on
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
No related issue: handoff-inbox item 20260913-034032 under program item 20260915-153000 (spawn budget per tool call); no GitHub issue was filed.
Stacked on #4185 (base branch
guardrails-run-guards-in-process-no-subs): the envelope parse rides on that PR'shook::jq_fieldsbuiltin parser in the synced lib. GitHub retargets it to main when #4185 merges.Summary
context-guard's
zone-crossing-inject.shfires on everyPostToolBatchandUserPromptSubmit. Each fire spawned a jq for the envelope and then the zone resolver (its own bash plus a jq) even when nothing the resolver reads had changed since the last fire, which is the common case: the statusline snapshot is rewritten only when the statusline renders.Fix
$STATE_DIR/$SESSION.seenmark records the inputs behind the last completed resolve in two ways: its mtime, stamped with a redirection and compared with-nt, and one flags line (z=<0|1> c=<0|1>) recording whetherzones.jsonand the compaction marker existed when that resolve ran, read back with builtinread. The fire exits before starting a process only when the per-session snapshot,zones.jsonand the compaction marker are all no newer than the mark AND both existence flags still match the current-eresults; a mark with no readable flags line never takes the skip. The mark moves only after the resolve persisted its markers, so a resolver failure, anunknownreading and a failed marker write are each retried next fire. A missing snapshot is never skippable. Skipping can only choose silence: no arrangement of timestamps or existence changes can manufacture an injection the full path would not have made.hook::jq_fields' builtin parser (zero spawns); above it keeps the single here-string jq (2 creations), because the helper's oversize fallback reads through a process substitution and measured 4. Plain routing through the helper would have made the large-payload path 9 to 11 creations; the branch is what keeps every cell at or below before. The65536literal mirrors the helper's private proof ceiling and is documented at the site.STATE_DIRresolution moves ahead of the resolver, so a session with no state root exits one process earlier.context-guard is bumped 0.7.64 to 0.7.65 with the numbers in the CHANGELOG, and the README gains a "Skipping the resolve when nothing moved" subsection carrying the table.
Verification
Job-object census (n=5, identical across reps; subject floor 3 =
bash -c,env, bash):No cell is worse than before. Small repeat fire wall p50 1448 ms to 237 ms (re-run 365 ms; the host is bimodal). On Windows a resolve costs 4 creations rather than 2 because
bash "$RESOLVER"hits thebin\bash.exewrapper, which re-spawnsusr\bin\bash.Crossing messages are byte-identical, asserted in the suite against a control session driven through the same zone sequence with no skipped fire. Suite:
zone-crossing-inject.test.sh78/1 to 97/2, where both failures arestrace: no usable traceon this host (Git Bash's cygwin strace rejects-e trace=; the second is the new strace block, not a regression);zone-gate26/0,post-compact-mark18/0. shellcheck clean;check-changelog-parity.sh --check-bump origin/mainand--checkgreen.Residuals: the strace pins (steady fire 0 creations / 1 execve; resolving fire 2 / 3) are set by reasoning and verified only on CI's Linux lane. The one miss window is the resolve itself: a snapshot written between the resolver's read and the stamp is marked seen and its crossing is reported one fire late, never lost, since the statusline rewrites the snapshot on its next render; on a filesystem or bash build that compares mtimes at whole-second granularity the window is up to one second. The README still carries the older "0.7.49 brought it to 3" paragraph; the new subsection supersedes it.
Related
🤖 Generated with Claude Code