Subsession receipts with a no-change guard - #2
Merged
Conversation
Closes KNOWN-LIMITS 5 (first-write-wins session receipts). Claude Code
fires SessionEnd more than once per session (clear, resume, exit), so a
session that was receipted and then kept working lost that later work.
Each qualifying firing now appends a NEW receipt indexed subsession
0, 1, ... n. Receipts stay cumulative rather than deltas: every firing
carries the whole transcript, so each receipt is a complete self-contained
summary that stands alone. Receipts are summaries (digests and counts,
never file contents), so the redundancy is cheap.
A no-change guard prevents inflation: if the transcript has not grown
since the last receipt for that session, nothing is appended.
- store: new appendReceiptGuarded(buildPayload) runs the whole
check-then-append inside the chain lock. Without it the subsession
decision is a TOCTOU: two firings both observe "no receipt yet" and both
append subsession 0. Reproduced with 4 barrier-synchronized processes,
which yielded 4 receipts instead of 1.
- parser: counts.transcriptEntries is the growth marker.
- ingest: owns the subsession decision so the hook and the manual CLI
share it. Returns {entry, skipped, subsession, sessionId}.
- hook: drops its own dedupe, reports the appended subsession or the skip.
Every existing guarantee kept (never stdout, never non-zero, catch all).
- views: morning-after reports total session receipts AND distinct
sessions, since one session can now produce several receipts and a
single number misleads. Session receipt shows the subsession line when
present, omitted for older receipts.
- MCP: query_receipts exposes subsession.
Tests: 96 passing. The concurrent-guard test was verified to fail against
an unguarded check-then-append before being accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Subsessions replaced first-write-wins, so the old wording is stale. The limit is now that receipts are cumulative and never superseding: each subsession carries the whole session up to that moment, no single receipt is 'the' record, and counting receipts is not counting sessions. Work after the final SessionEnd firing is still never captured. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes
KNOWN-LIMITSitem 5. Follows the merged install-readiness and session-receipt work in #1.The problem
Claude Code fires
SessionEndmore than once for the same session (on clear, on resume, on exit). The hook deduped by session id, so the first firing won and everything after it was lost. A session that got receipted and then kept working left no record of the later work.The fix
Each firing that carries new activity appends a NEW receipt, indexed
subsession0, 1, ... n.Receipts stay cumulative rather than deltas. Every firing carries the whole transcript, so each receipt is a complete self-contained summary that stands on its own. Receipts are summaries (digests and counts, never file contents), so the redundancy is cheap and buys back the property that any single receipt is independently meaningful.
A no-change guard prevents inflation: if the transcript has not grown since the last receipt for that session, nothing is appended.
The race this had to avoid
The subsession decision reads the chain and then appends, which is a time-of-check-to-time-of-use race. Two firings for the same session can both observe "no receipt yet" and both append
subsession 0.appendReceiptGuarded(buildPayload)runs the whole check-then-append inside the existing chain lock. The callback receives the freshly re-read tail and returns either a payload ornullto skip.Reproduced before fixing: 4 barrier-synchronized processes ingesting the same transcript produced 4 receipts instead of 1. The test was verified to fail against an unguarded check-then-append before being accepted, because a concurrency test that passes both before and after a fix is worthless.
Changes
src/store/index.js: newappendReceiptGuarded. ExistingappendReceipt,reload,verifyuntouched.src/parser/index.js:counts.transcriptEntriesis the growth marker.src/ingest/index.js: owns the subsession decision so the hook and the manual CLI share one implementation. Returns{entry, skipped, subsession, sessionId}.bin/hook-session-end.js: drops its own dedupe. Every existing guarantee kept: never writes stdout, never exits non-zero, catches all failures, bounded stdin read.src/views/index.js: morning-after reports total session receipts AND distinct sessions, since one session can now produce several receipts and a single number misleads. Session receipts show a subsession line when present, omitted for older receipts.src/mcp/server.js:query_receiptsexposessubsession.KNOWN-LIMITS.md: item 5 rewritten for the new behavior.Verification