Skip to content

fix(claude): count stale nodes off post-edit's critical path (#483) - #672

Open
johnybradshaw wants to merge 2 commits into
trailhq:mainfrom
johnybradshaw:fix/post-edit-stale-count-background
Open

johnybradshaw wants to merge 2 commits into
trailhq:mainfrom
johnybradshaw:fix/post-edit-stale-count-background

Conversation

@johnybradshaw

Copy link
Copy Markdown

Summary

On mid-size repos, the post-edit hook blocks every Write/Edit for about 8 seconds and then reports 0 stale (#483). This PR moves the stale count off the edit path. It also fixes a second bug: graft check --json truncates its own output on a pipe.

1. fix(claude): count stale nodes in a detached child (#483)

handlePostEdit ran graft check synchronously under the 8s CHILD_TIMEOUT_MS. On a ~900-file repo the check alone takes 6.5–9.5s, CPU-bound. Across 127 recorded post-edit runs, each took 6.3–8.4s, and a timeout was written as staleCount: 0.

Now handlePostEdit sets dirty and lastFile and spawns dist/claude/stale-count-run.js detached, the way handleStop spawns sync-run.js. It uses a GRAFT_TEST_STALE_COUNT_RUN seam, like GRAFT_TEST_SYNC_RUN. The runner works as follows:

  • It checks under a 30s budget, not sync-run's 120s. A tree too large to check in 30s would otherwise burn a core for two minutes per edit burst and still get nothing.
  • It takes its own lock, .cache/stale-check.lock, so one check runs at a time and the sync lock is never held. A lock older than the budget plus 10s is reclaimed, and the runner only removes a lock whose pid is its own.
  • It never invents a 0. On a timeout or unparseable output it leaves the last count standing.
  • It never overwrites a newer answer. It skips the write if the graph is clean, a sync is running, or syncedAt is later than the check's start.
  • The blast-radius, overlap and learnings output is unchanged.

2. fix(cli): let check --json finish writing before a stale exit

I found this while verifying the change end to end. Even when the check finished inside the budget, the count stayed at 0. graft check called process.exit(1) straight after console.log. On POSIX, stdout to a pipe is asynchronous, so whatever was still queued was dropped:

reader bytes received (full output 530,396)
file redirect 530,396
| wc -c 65,536
execFileSync / spawnSync 146,176

graph.pendingIds lists every unsummarised node, so any repo of a few thousand symbols built without --deep produces output past that size. post-edit's parse then failed and wrote 0. Anyone piping graft check --json into jq hits the same thing.

The fix is process.exitCode = 1, which other commands in cli.ts already use. One side effect to be aware of: postAction now runs for a failing check, so the query telemetry event counts stale checks as well as clean ones. Before this, only passing checks were counted.

Relation to #485 and #450

Test plan

  • New claude-hooks tests. post-edit spawns the runner with the repo dir and returns without waiting on it, leaving staleCount for the child. runStaleCount covers: recovering JSON from a non-zero exit; releasing its lock; keeping the last count on null; each of the three sync guards, plus a sync from before the check not blocking; the live lock; and reclaiming a stale lock. The existing GRAFT_DIR --dir test moved to the runner.
  • Mutation-checked. Removing each guard, the lock check or release, the stale-lock reclaim, stdout recovery, --dir, or the spawn, or making the spawn synchronous, fails at least one test.
  • New cli-check-pipe test. A 12,000-symbol repo is made stale, graft check --json is read through spawnSync, and the test asserts exit status 1 and that the JSON parses. Without the fix it fails with SyntaxError: Unterminated string in JSON at position 146176. A size assertion makes the fixture fail loudly if it ever shrinks below what a pipe can hide.
  • Full suite: 2530 pass. All failures are also on main in the same environment (unvendored grammars, symlink and git-tracking cases), apart from wall-clock-bound tests that failed under full-suite load and pass alone, before and after: trace-shell, skills-answers-prompt, upkeep-hooks.
  • End to end, with the built dist, on a copy of a real 553-file repo after an edit: the hook took 220–250ms, against 8.27s for unpatched 0.23.1 on a similar repo. The background check finished in about 11s, released its lock, and wrote staleCount: 1, matching graft check.

Fixes #483

🤖 Generated with Claude Code

@trailhq-graft

trailhq-graft Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

2 areas changed → 3 areas can be affected. 15 dependent symbols, depth 2.
Tests: no test reaches Check Command; 1 area updated its tests.
Tag: @anirudhkumar-nanonets — Hook I/O, Check Command · @shhdwi — 5 of 5 areas

flowchart TB
  A0(("Skill Lifecycle Hooks<br/>8 symbols"))
  A1(("Lesson Gating<br/>6 symbols"))
  A2(("Skill Session Tracing<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Skill Lifecycle Hooks 8 src/claude/prompt-hook.ts:L1-L298 prompt-hook.ts — imports, depth 1 Hook I/O
Lesson Gating 6 src/skills/lesson-gate.ts:L1-L320 lesson-gate.ts — imports, depth 2 Hook I/O
Skill Session Tracing 1 src/trace/skill-session.ts:L1-L265 skill-session.ts — imports, depth 1 Hook I/O
Who knows this code — 2 people across 5 areas
Area Who knows it
Hook I/O · changed @anirudhkumar-nanonets — 16 commits, last yesterday · @shhdwi — 13 commits, last today
Check Command · changed @anirudhkumar-nanonets — 61 commits, last yesterday · @shhdwi — 39 commits, last today
Skill Lifecycle Hooks · affected @shhdwi — 2 commits, last today
Lesson Gating · affected @shhdwi — 2 commits, last today
Skill Session Tracing · affected @shhdwi — 1 commit, last yesterday

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 15 dependent symbols, grouped by area

Skill Lifecycle Hooks — 8 symbols in 8 files

  • src/claude/prompt-hook.ts:L1-L298 — prompt-hook.ts (imports, depth 1)
    7: * out of time must not take the learnings down with it. The graph ask (`graft
  • src/claude/prompt-skills.ts:L1-L327 — prompt-skills.ts (imports, depth 1)
    5: * 3-character floor and before `graft ask`.
  • src/claude/stop.ts:L1-L213 — stop.ts (imports, depth 1)
    5: * reply said what graft saved, and one background graph sync when an edit
  • src/claude/post-bash.ts:L1-L186 — post-bash.ts (imports, depth 2)
  • src/claude/skills-hook-util.ts:L1-L83 — skills-hook-util.ts (imports, depth 2)
  • src/claude/start-skills.ts:L1-L269 — start-skills.ts (imports, depth 2)
  • src/claude/stop-skills.ts:L1-L236 — stop-skills.ts (imports, depth 2)
  • src/claude/walls-hook.ts:L1-L247 — walls-hook.ts (imports, depth 2)

Lesson Gating — 6 symbols in 6 files

  • src/skills/lesson-gate.ts:L1-L320 — lesson-gate.ts (imports, depth 2)
    63: subagent: `${NOT}only the main session can keep a lesson.`,
  • src/skills/offer-lesson.ts:L1-L125 — offer-lesson.ts (imports, depth 2)
  • src/skills/offer-moment.ts:L1-L307 — offer-moment.ts (imports, depth 2)
  • src/skills/save-gate.ts:L1-L363 — save-gate.ts (imports, depth 2)
    159: * The main session's tool calls in the transcript's last 4 MB, oldest first, as the host's dialect reads them (4.16):
  • src/skills/save-verify.ts:L1-L390 — save-verify.ts (imports, depth 2)
    63: subagent: `${NOT}only the main session can keep a skill.`,
  • src/skills/update-gate.ts:L1-L345 — update-gate.ts (imports, depth 2)
    65: subagent: `${NOT}only the main session can change a skill.`,

Skill Session Tracing — 1 symbol in 1 file

  • src/trace/skill-session.ts:L1-L265 — skill-session.ts (imports, depth 1)
    21: import { payloadSessionId } from '../claude/hook-io.js';
Test signal per changed area — 1 ✓ · 1 ✗

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Hook I/O — 1 of 7 reached · 1 test file changed here: test/claude-hooks.test.ts
    • not reached: staleCountInBackground, handlePostEdit, acquire, countStale, main, release
  • ✗ Check Command — 0 of 1 reached · no test file reaches it
    • not reached: runCheckCommand
52 test suites also reference this code

52 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/claude-copy-dashes.test.ts
  • test/claude-hook-timeouts.test.ts
  • test/claude-hooks-perf.test.ts
  • test/claude-hooks-skills.test.ts
  • test/claude-host-standdown.test.ts
  • test/claude-pre-bash-skills.test.ts
  • test/claude-prompt-learnings.test.ts
  • test/claude-prompt-private.test.ts
  • test/claude-session-start-learnings.test.ts
  • test/claude-skills-journey-fixtures.ts
  • test/claude-stop-moments.test.ts
  • test/claude-stop-trace.test.ts
  • test/claude-stop.test.ts
  • test/claude-tally.test.ts
  • test/hosts-codex-hooks.test.ts
  • test/skills-agent-trust.test.ts
  • test/skills-anchors-wide-review.test.ts
  • test/skills-bound-values.test.ts
  • test/skills-check-readonly.test.ts
  • test/skills-consent-fixtures.ts
  • …32 more

graft blast · origin/main...HEAD · depth 2 · 6 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Oct 22, 2026
johnybradshaw and others added 2 commits October 23, 2026 08:37
`graft check` exited with process.exit(1) straight after console.log. On a
pipe, Node writes stdout asynchronously, so exit() dropped whatever was still
queued: a reader got the first 64 KiB (146 KiB through spawnSync's socket)
and could not parse it. pendingIds lists every unsummarised node, so a repo
of a few thousand symbols built without --deep is past that. post-edit's
stale count is one such reader, and wrote 0 whenever its parse failed.

Set process.exitCode instead, as other commands here already do. One side
effect: the postAction hook now also runs for a failing check, so the
`query` telemetry event counts stale checks as well as clean ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#483)

post-edit ran `graft check` synchronously under an 8s child timeout to fill
staleCount for the statusline. On a ~900-file repo the check takes
6.5-9.5s, so every Write/Edit blocked for about 8s, and a timeout was written
as staleCount: 0.

post-edit now marks the graph dirty and spawns stale-count-run.js detached,
the way Stop spawns sync-run.js. The runner checks under a 30s budget and
writes the count back, with these guards:
- it holds its own lock, so one check runs at a time and the sync lock is
  never held;
- it keeps the last count when the check times out or prints nothing
  parseable;
- it skips the write if the graph is clean, a sync is running, or a sync
  finished after the check started.

Measured on a 553-file repo with the built dist: the hook takes 220-250ms,
and the count lands about 10s later, matching `graft check`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnybradshaw
johnybradshaw force-pushed the fix/post-edit-stale-count-background branch from 29b1c87 to bd77195 Compare October 23, 2026 07:48
github-actions Bot added a commit that referenced this pull request Oct 23, 2026

This branch has not been deployed

No deployments
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.

post-edit hook burns its full 8 s child timeout on every edit in large repos and discards the result

1 participant