From 45b72989e651695260217f69542f38d3416d5045 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 13:42:58 -0700 Subject: [PATCH] fix(hooks): timeline Stop hook reads a 256KB tail instead of the whole file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop hook runs on EVERY Claude Code turn machine-wide and re-read + JSON-parsed the entire timeline each time, scaling to the 10MB size cap (~100-300ms per turn of pure overhead). It now reads only the last 256KB via fstat + positioned read, discarding the first partial line when the window starts mid-file. Semantics: a dangling "started" older than the last 256KB of appends belongs to a session long gone — beyond repair interest. The window can never fabricate a dangling entry ("completed" is always appended AFTER its "started", so any started inside the window has its completion inside the window too), so idempotency holds. The fail-open contract is unchanged: exit 0 always, size cap kept, deadline re-checked before the write. New test: a >256KB timeline where a recent dangling entry still gets repaired while an old out-of-window dangler is left alone; all existing fail-open cases pass unchanged. Co-Authored-By: Claude Fable 5 --- hosts/claude/hooks/timeline-stop-hook.ts | 39 ++++++++++++++++++++++-- test/timeline-stop-hook.test.ts | 31 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/hosts/claude/hooks/timeline-stop-hook.ts b/hosts/claude/hooks/timeline-stop-hook.ts index 1b0b3f189..91156184d 100755 --- a/hosts/claude/hooks/timeline-stop-hook.ts +++ b/hosts/claude/hooks/timeline-stop-hook.ts @@ -15,9 +15,17 @@ * stdin, unreadable slug). Errors go to ~/.gstack/hook-errors.log, * best-effort. * - Internal time budget (~2s): work is bounded up front — the timeline is - * skipped entirely over a size cap, and the deadline is re-checked before - * the write. Claude Code's own hook timeout is the outer belt. + * skipped entirely over a size cap, only the last TAIL_WINDOW_BYTES are + * read and parsed (P3: this hook runs on EVERY Stop event machine-wide, + * and a full read+parse scaled to the cap at ~100-300ms/turn), and the + * deadline is re-checked before the write. Claude Code's own hook + * timeout is the outer belt. * - Append-only: never rewrites timeline.jsonl. + * - Tail-window semantics: a dangling "started" older than the last 256KB + * of appends belongs to a session long gone — beyond repair interest. + * A "completed" is always appended AFTER its "started", so any started + * inside the window has its completion inside the window too: the window + * can never fabricate a dangling entry, and idempotency holds. * * Correlation limits, on purpose: the preamble's session id is a shell-local * "$$-epoch", not the Claude session id this hook receives, so entries can't @@ -33,8 +41,33 @@ import { runBin } from './spawn-bin'; const DEADLINE_MS = 2000; const MAX_TIMELINE_BYTES = 10 * 1024 * 1024; +const TAIL_WINDOW_BYTES = 256 * 1024; const startedAt = Date.now(); +/** + * Read only the last TAIL_WINDOW_BYTES of the timeline (P3). When the window + * starts mid-file, the first (partial) line is discarded — its entry is + * outside the window by definition. Throws on I/O errors; the caller owns + * the fail-open handling. + */ +function readTimelineTail(timelinePath: string, size: number): string { + const fd = fs.openSync(timelinePath, 'r'); + try { + const offset = Math.max(0, size - TAIL_WINDOW_BYTES); + const length = size - offset; + const buf = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buf, 0, length, offset); + let text = buf.subarray(0, bytesRead).toString('utf8'); + if (offset > 0) { + const firstNewline = text.indexOf('\n'); + text = firstNewline === -1 ? '' : text.slice(firstNewline + 1); + } + return text; + } finally { + fs.closeSync(fd); + } +} + function stateRoot(): string { return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); } @@ -102,7 +135,7 @@ function main(): void { let raw: string; try { - raw = fs.readFileSync(timelinePath, 'utf8'); + raw = readTimelineTail(timelinePath, stat.size); } catch (err) { logHookError(`could not read timeline: ${err instanceof Error ? err.message : String(err)}`); return; diff --git a/test/timeline-stop-hook.test.ts b/test/timeline-stop-hook.test.ts index f2d0b3d5b..e4498b542 100644 --- a/test/timeline-stop-hook.test.ts +++ b/test/timeline-stop-hook.test.ts @@ -158,6 +158,37 @@ describe('timeline-stop-hook (#2553, F5 fail-open)', () => { expect(runHook('').exitCode).toBe(0); }); + test('tail window (P3): a recent dangling entry in a >256KB timeline is still repaired', () => { + const lines: string[] = []; + // An old dangling entry that falls OUTSIDE the 256KB tail window — + // beyond repair interest by design (its session is long gone). + lines.push(JSON.stringify({ skill: 'review', event: 'started', session: 'old-1' })); + // >512KB of closed pairs pushes the old entry well past the window while + // proving windowed parsing still walks real entries. + let n = 0; + while (lines.length * 100 < 512 * 1024) { + lines.push( + JSON.stringify({ skill: 'qa', event: 'started', session: `pad-${n}`, pad: '#'.repeat(40) }), + ); + lines.push(JSON.stringify({ skill: 'qa', event: 'completed', session: `pad-${n}`, outcome: 'success' })); + n++; + } + lines.push(JSON.stringify({ skill: 'ship', event: 'started', session: 'recent-9' })); + fs.writeFileSync(timelinePath, lines.join('\n') + '\n'); + expect(fs.statSync(timelinePath).size).toBeGreaterThan(256 * 1024); + + const r = runHook(stopPayload()); + expect(r.exitCode).toBe(0); + const repairs = timelineEntries().filter((e) => e.source === 'stop-hook'); + expect(repairs).toHaveLength(1); + expect(repairs[0]).toMatchObject({ + skill: 'ship', + event: 'completed', + outcome: 'unknown', + session: 'recent-9', + }); + }); + test('oversized timeline is skipped, untouched, and still exits 0 (fail-open size cap)', () => { const line = JSON.stringify({ skill: 'qa', event: 'started', session: '66-6' }) + '\n'; const filler = '#'.repeat(1024 * 1024);