mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-23 13:20:48 +02:00
fix(hooks): timeline Stop hook reads a 256KB tail instead of the whole file
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aa6c73821f
commit
45b72989e6
@@ -15,9 +15,17 @@
|
|||||||
* stdin, unreadable slug). Errors go to ~/.gstack/hook-errors.log,
|
* stdin, unreadable slug). Errors go to ~/.gstack/hook-errors.log,
|
||||||
* best-effort.
|
* best-effort.
|
||||||
* - Internal time budget (~2s): work is bounded up front — the timeline is
|
* - 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
|
* skipped entirely over a size cap, only the last TAIL_WINDOW_BYTES are
|
||||||
* the write. Claude Code's own hook timeout is the outer belt.
|
* 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.
|
* - 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
|
* 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
|
* "$$-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 DEADLINE_MS = 2000;
|
||||||
const MAX_TIMELINE_BYTES = 10 * 1024 * 1024;
|
const MAX_TIMELINE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const TAIL_WINDOW_BYTES = 256 * 1024;
|
||||||
const startedAt = Date.now();
|
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 {
|
function stateRoot(): string {
|
||||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||||
}
|
}
|
||||||
@@ -102,7 +135,7 @@ function main(): void {
|
|||||||
|
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(timelinePath, 'utf8');
|
raw = readTimelineTail(timelinePath, stat.size);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logHookError(`could not read timeline: ${err instanceof Error ? err.message : String(err)}`);
|
logHookError(`could not read timeline: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -158,6 +158,37 @@ describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
|
|||||||
expect(runHook('').exitCode).toBe(0);
|
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)', () => {
|
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 line = JSON.stringify({ skill: 'qa', event: 'started', session: '66-6' }) + '\n';
|
||||||
const filler = '#'.repeat(1024 * 1024);
|
const filler = '#'.repeat(1024 * 1024);
|
||||||
|
|||||||
Reference in New Issue
Block a user