You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #2002, which fixed the tail of hook latency but not the median. Filing the
measured state so the remaining work is picked up from evidence rather than re-derived.
Profiling createRedactedBlobFromFile put redaction at 99.7% of the checkpoint blob
write (git object writing was 174ms of a 66.7s pass on 70MB), and the CPU profile put 82% of that in betterleaks' regex ruleset. full.jsonl holds the full session and was
re-redacted from byte zero every checkpoint, so a session with N checkpoints re-redacted
O(N²) bytes — which is also what inflates the multi-GB packs.
Sharding plus prefix reuse took a 14-checkpoint session growing to 70MB from ~66s to
~2.0s on the final Stop.
What is still open
1. ~~The ~3s p50 floor is unexplained~~ — ANSWERED, see the re-measure below
It was substantially the redaction path, and #2002 fixed it: population p50 2326ms →
1103ms, with write_temporary_checkpoint and redact_transcript gone from slow traces
entirely. Evidence and method are in the re-measure section.
Kept for the ruling-out work, which stays useful if the floor ever returns: it was not
the transcript-flush maxWait (its warning appears 0 times in real logs), the
trail-enablement refresh (detached subprocess by design), git-refs vs git-branch backend, telemetry, checkpoint history, authentication, or local_dev. Size was not the driver
either — stop was flat across a 235× range of pack size, and 8KB → 1.2MB of transcript
added only ~125ms. The original distribution was bimodal with a hard floor at ~3s and a
near-empty 2500-2999ms gap beneath it, which is what made a conditional timeout look more
likely than variable work.
2. process_sessions — post-commit condensation
Dominant step in 27 of 93 slow traces. Independently confirms docs/architecture/commit-hook-perf-analysis.md:
~73–103ms per accumulated session, N sessions per commit, PostCommit dominating. That doc
already lists concrete fixes (batch condensation into one commit, prune stale ENDED
sessions, skip the orphan check for sessions with LastCheckpointID). Nothing has been
done. Probably the largest remaining win.
2b. The compact transcript is generated twice per Stop
writeCompactTranscript has two call sites — checkpoint/persistent.go:1092 (checkpoint
write) and :2069 (Stop finalize rewrite) — and compact.FullWithBoundary compacts the whole session each time. Measured at 457ms + 447ms = 904ms of a 2505ms Stop, which
makes it the largest single item in a Stop now that redaction is fixed.
Same shape as the problem #2107 just solved, and possibly the same remedy. Found during the
2026-08-23 re-measure, not from first principles — it only became visible once redaction
stopped dominating.
3. The transcript-flush sentinel is dead code costing a fixed ~520ms per Stop
waitForTranscriptFlush waits for a hook_progress transcript entry containing "hooks claude-code stop". Claude Code no longer writes it — 0 occurrences across 40
real transcripts. So it always falls through to settle-on-stability, which structurally
cannot return before quietWindow (500ms) + one 50ms poll.
There is nothing to revive: what Claude Code writes now is {"type":"system","subtype":"stop_hook_summary"}, emitted after the hook returns (it
reports the hook's own durationMs), so waiting on it would deadlock. Measured separately:
the final assistant entry lands 33ms before the hook starts, so the transcript is already
complete when the wait begins.
Needs a decision, not more investigation — shortening the window trades against reading a
truncated transcript, which is why it exists. Suggested shape: return as soon as the size
is stable across two polls and the last line parses as complete JSON.
4. Prefix reuse covers only the shadow write — DONE in #2107
Condensation and the Stop finalize rewrite now reuse prefixes too, via checkpoint.RedactTranscriptCached. Promoted ahead of the rest because it stopped being
latency and became breakage: a coworker on a 65MB Codex rollout hit Codex's 30s Stop-hook
timeout (30.203s, process killed, finalize rewrite left incomplete).
Two things found while doing it, both worth knowing:
A git blob is not a free place to cache bytes. The first cut stored the reused prefix
as a blob, on the assumption git would dedup it. go-git deflates the whole payload before
dotgit discovers the object exists — dedup saves the rename, not the compression — so it
cost ~1-2s of zlib per checkpoint. Worse, the store chunks at agent.MaxChunkSize (50MB),
so above that the whole-transcript blob matches no chunk, is never deduped, and lingers
unreachable until git gc prunes it and silently reverts the cache to full redaction.
The prefix is a plain file in the cache dir now.
The redaction test suite was most of the checkpoint package's runtime. The 1MiB size
gate forced every test to build and redact a megabyte of realistic content; 17 tests cost
4m57s under -race and timed out CI at 10m. The gate is now test-overridable and the
package runs in ~22s — faster than before this work started.
Re-measured 2026-08-23
Corpus: 95 .entire/logs/entire.log files, 57,619 lines, 1,465 paired
turn-end→shadow-commit spans and 287perf spans. Both comparison windows below sit after#1999, so the deleted entire-dev launcher is not a confound between them.
The two redaction-path steps accounted for 78 of 235 slow traces and vanished entirely.
Do not read the slow-trace p50 as a regression
It rose 2592ms → 4063ms, and that is a selection effect, not a slowdown. The 1.5s
threshold is fixed, so removing a cluster of just-over-threshold traces raises the p50 of
what remains: the 1.5–3s band collapsed from 137 traces to 11, leaving the sample
dominated by genuinely slow network pushes. The unbiased metric above is the one that
answers "did it get faster".
What this reordered
process_sessions (item 2) became the top actionable cost — the only remaining non-network
item at 29.6% of slow-trace time. push_checkpoint_refs is larger at 69.7% but is network
I/O to the remote, so it may be inherent rather than a defect; it has never been examined
and is now the biggest single line item. The double compact-transcript generation (item 2b)
and the un-instrumented sentinel (item 3) both come out of this data.
Caveats
n is small post-fix (28 slow traces, 15 paired spans); the split assumes the local binary
picked up #2002 with the 08-21 nightly; and day-to-day workload varies enough to move these
numbers on its own (08-05..08-07 ran at a 9.4–11.2s p50). The step-name disappearances are
much stronger evidence than any of the aggregates.
Re-measuring
Slow root perf spans log at WARN since #1984, so this no longer needs a bespoke harness.
Aggregation shipped in #2092, so re-measuring is now one command:
entire doctor trace --summary --slow --last 500
It prints per-hook N / slow / p50 / p90 / max plus the step that dominates each hook, and --json emits raw entries for scripting. Two things to know when comparing against numbers
taken before #2092:
It reads one repo's log.doctor trace resolves .entire/logs/entire.log from the
current worktree, so a cross-repo sweep still needs a grep -rh '"slow":true' over several
checkouts. Run the command per repo rather than assuming it aggregates them.
Dominance is time-weighted, not counted. The ad-hoc one-liner this replaces ranked
steps by how often each was a trace's largest, which ties a 3900ms offender with a 150ms
one and then resolves alphabetically — it can name the wrong step outright. Any ranking
produced by counting should be re-derived, not carried forward.
Two traps, both of which produced wrong answers during the original investigation:
Don't benchmark in this repo.Obsolete since refactor(hooks)!: remove local_dev mode #1999 (2026-08-17), which removed local_dev mode and deleted scripts/entire-dev. Hooks here now exec the plain entire
binary from PATH, so timings taken in this repo are ordinary product timings. This trap was
real while it lasted — the launcher go build/go run'd the CLI on every invocation
(~1.53s warm), a cost no user paid, and it silently dominated any measurement. It is kept
here only so old numbers can be read correctly: anything measured in this repo before
2026-08-17 is inflated by it.
git clone does not copy refs/entire/*. A clone has no checkpoint history, so it
cannot reproduce costs that scale with accumulated sessions. Copy the repo instead.
Also: a sequential A/B lets page-cache warming leak across the boundary — it showed a fake
49% win for the object cache in #1985, which was 2% when interleaved.
Unrelated loose end from the same investigation, worked around in #2006 and worth
reporting upstream. Corrected 2026-08-23 — an earlier version of this paragraph named muesli/cancelreader, which is the wrong target: Charm has re-implemented it inside charmbracelet/ultraviolet, and the defect came across verbatim at poll_linux.go:123
and poll_bsd.go:113 (ultraviolet@v0.0.0-20260811164956), which is the code we
actually run. Both read r.file.Fd()after the blocking wake returns, racing a
caller's concurrent Close(); the fds are already known at construction, so caching
them as ints removes the race with no API or behaviour change. muesli/cancelreader
carries the same lines but is effectively unmaintained (last substantive merge
2022-06-22; 7 open PRs spanning 2022-2026, none merged), so a PR there would not land.
Separately, bubbletea's shutdown(kill=true) skips waitForReadLoop() and exposes no
API to await the reader, so a caller has no safe moment to reclaim a *os.File it
passed in — that one is a semantics question for the maintainers, not a drive-by
patch.
Follow-up to #2002, which fixed the tail of hook latency but not the median. Filing the
measured state so the remaining work is picked up from evidence rather than re-derived.
What #2002 fixed
Profiling
createRedactedBlobFromFileput redaction at 99.7% of the checkpoint blobwrite (git object writing was 174ms of a 66.7s pass on 70MB), and the CPU profile put
82% of that in betterleaks' regex ruleset.
full.jsonlholds the full session and wasre-redacted from byte zero every checkpoint, so a session with N checkpoints re-redacted
O(N²) bytes — which is also what inflates the multi-GB packs.
Sharding plus prefix reuse took a 14-checkpoint session growing to 70MB from ~66s to
~2.0s on the final Stop.
What is still open
1. ~~The ~3s p50 floor is unexplained~~ — ANSWERED, see the re-measure below
It was substantially the redaction path, and #2002 fixed it: population p50 2326ms →
1103ms, with
write_temporary_checkpointandredact_transcriptgone from slow tracesentirely. Evidence and method are in the re-measure section.
Kept for the ruling-out work, which stays useful if the floor ever returns: it was not
the transcript-flush
maxWait(its warning appears 0 times in real logs), thetrail-enablement refresh (detached subprocess by design), git-refs vs git-branch backend,
telemetry, checkpoint history, authentication, orlocal_dev. Size was not the drivereither —
stopwas flat across a 235× range of pack size, and 8KB → 1.2MB of transcriptadded only ~125ms. The original distribution was bimodal with a hard floor at ~3s and a
near-empty 2500-2999ms gap beneath it, which is what made a conditional timeout look more
likely than variable work.
2.
process_sessions— post-commit condensationDominant step in 27 of 93 slow traces. Independently confirms
docs/architecture/commit-hook-perf-analysis.md:~73–103ms per accumulated session, N sessions per commit, PostCommit dominating. That doc
already lists concrete fixes (batch condensation into one commit, prune stale ENDED
sessions, skip the orphan check for sessions with
LastCheckpointID). Nothing has beendone. Probably the largest remaining win.
2b. The compact transcript is generated twice per Stop
writeCompactTranscripthas two call sites —checkpoint/persistent.go:1092(checkpointwrite) and
:2069(Stop finalize rewrite) — andcompact.FullWithBoundarycompacts thewhole session each time. Measured at 457ms + 447ms = 904ms of a 2505ms Stop, which
makes it the largest single item in a Stop now that redaction is fixed.
Same shape as the problem #2107 just solved, and possibly the same remedy. Found during the
2026-08-23 re-measure, not from first principles — it only became visible once redaction
stopped dominating.
3. The transcript-flush sentinel is dead code costing a fixed ~520ms per Stop
waitForTranscriptFlushwaits for ahook_progresstranscript entry containing"hooks claude-code stop". Claude Code no longer writes it — 0 occurrences across 40real transcripts. So it always falls through to settle-on-stability, which structurally
cannot return before
quietWindow(500ms) + one 50ms poll.There is nothing to revive: what Claude Code writes now is
{"type":"system","subtype":"stop_hook_summary"}, emitted after the hook returns (itreports the hook's own
durationMs), so waiting on it would deadlock. Measured separately:the final assistant entry lands 33ms before the hook starts, so the transcript is already
complete when the wait begins.
Needs a decision, not more investigation — shortening the window trades against reading a
truncated transcript, which is why it exists. Suggested shape: return as soon as the size
is stable across two polls and the last line parses as complete JSON.
4.
Prefix reuse covers only the shadow write— DONE in #2107Condensation and the Stop finalize rewrite now reuse prefixes too, via
checkpoint.RedactTranscriptCached. Promoted ahead of the rest because it stopped beinglatency and became breakage: a coworker on a 65MB Codex rollout hit Codex's 30s Stop-hook
timeout (30.203s, process killed, finalize rewrite left incomplete).
Two things found while doing it, both worth knowing:
as a blob, on the assumption git would dedup it. go-git deflates the whole payload before
dotgit discovers the object exists — dedup saves the rename, not the compression — so it
cost ~1-2s of zlib per checkpoint. Worse, the store chunks at
agent.MaxChunkSize(50MB),so above that the whole-transcript blob matches no chunk, is never deduped, and lingers
unreachable until
git gcprunes it and silently reverts the cache to full redaction.The prefix is a plain file in the cache dir now.
checkpointpackage's runtime. The 1MiB sizegate forced every test to build and redact a megabyte of realistic content; 17 tests cost
4m57s under
-raceand timed out CI at 10m. The gate is now test-overridable and thepackage runs in ~22s — faster than before this work started.
Re-measured 2026-08-23
Corpus: 95
.entire/logs/entire.logfiles, 57,619 lines, 1,465 pairedturn-end→shadow-commit spans and 287
perfspans. Both comparison windows below sitafter #1999, so the deleted
entire-devlauncher is not a confound between them.#2002 worked, and the ~3s floor is gone
Unbiased population metric (every turn, not just slow ones), non-CLI repos:
Against the original 3251ms p50 across 1,379 pre-fix spans, that closes item 1.
The composition of slow traces is the stronger evidence, because it names steps rather than
moving an aggregate:
push_checkpoint_refsprocess_sessionswrite_temporary_checkpointredact_transcriptThe two redaction-path steps accounted for 78 of 235 slow traces and vanished entirely.
Do not read the slow-trace p50 as a regression
It rose 2592ms → 4063ms, and that is a selection effect, not a slowdown. The 1.5s
threshold is fixed, so removing a cluster of just-over-threshold traces raises the p50 of
what remains: the 1.5–3s band collapsed from 137 traces to 11, leaving the sample
dominated by genuinely slow network pushes. The unbiased metric above is the one that
answers "did it get faster".
What this reordered
process_sessions(item 2) became the top actionable cost — the only remaining non-networkitem at 29.6% of slow-trace time.
push_checkpoint_refsis larger at 69.7% but is networkI/O to the remote, so it may be inherent rather than a defect; it has never been examined
and is now the biggest single line item. The double compact-transcript generation (item 2b)
and the un-instrumented sentinel (item 3) both come out of this data.
Caveats
n is small post-fix (28 slow traces, 15 paired spans); the split assumes the local binary
picked up #2002 with the 08-21 nightly; and day-to-day workload varies enough to move these
numbers on its own (08-05..08-07 ran at a 9.4–11.2s p50). The step-name disappearances are
much stronger evidence than any of the aggregates.
Re-measuring
Slow root perf spans log at WARN since #1984, so this no longer needs a bespoke harness.
Aggregation shipped in #2092, so re-measuring is now one command:
It prints per-hook N / slow / p50 / p90 / max plus the step that dominates each hook, and
--jsonemits raw entries for scripting. Two things to know when comparing against numberstaken before #2092:
doctor traceresolves.entire/logs/entire.logfrom thecurrent worktree, so a cross-repo sweep still needs a
grep -rh '"slow":true'over severalcheckouts. Run the command per repo rather than assuming it aggregates them.
steps by how often each was a trace's largest, which ties a 3900ms offender with a 150ms
one and then resolves alphabetically — it can name the wrong step outright. Any ranking
produced by counting should be re-derived, not carried forward.
Two traps, both of which produced wrong answers during the original investigation:
Don't benchmark in this repo.Obsolete since refactor(hooks)!: remove local_dev mode #1999 (2026-08-17), which removedlocal_devmode and deletedscripts/entire-dev. Hooks here now exec the plainentirebinary from PATH, so timings taken in this repo are ordinary product timings. This trap was
real while it lasted — the launcher
go build/go run'd the CLI on every invocation(~1.53s warm), a cost no user paid, and it silently dominated any measurement. It is kept
here only so old numbers can be read correctly: anything measured in this repo before
2026-08-17 is inflated by it.
git clonedoes not copyrefs/entire/*. A clone has no checkpoint history, so itcannot reproduce costs that scale with accumulated sessions. Copy the repo instead.
Also: a sequential A/B lets page-cache warming leak across the boundary — it showed a fake
49% win for the object cache in #1985, which was 2% when interleaved.
Unrelated loose end from the same investigation, worked around in #2006 and worth
reporting upstream. Corrected 2026-08-23 — an earlier version of this paragraph named
muesli/cancelreader, which is the wrong target: Charm has re-implemented it insidecharmbracelet/ultraviolet, and the defect came across verbatim atpoll_linux.go:123and
poll_bsd.go:113(ultraviolet@v0.0.0-20260811164956), which is the code weactually run. Both read
r.file.Fd()after the blocking wake returns, racing acaller's concurrent
Close(); the fds are already known at construction, so cachingthem as ints removes the race with no API or behaviour change.
muesli/cancelreadercarries the same lines but is effectively unmaintained (last substantive merge
2022-06-22; 7 open PRs spanning 2022-2026, none merged), so a PR there would not land.
Separately, bubbletea's
shutdown(kill=true)skipswaitForReadLoop()and exposes noAPI to await the reader, so a caller has no safe moment to reclaim a
*os.Fileitpassed in — that one is a semantics question for the maintainers, not a drive-by
patch.