Files
gstack/test/timeline-stop-hook.test.ts
T
Garry TanandClaude Fable 5 8c1192d0dd fix(hooks): timeline repair counts started vs completed per key instead of set-masking
The dangling-event repair kept only the FIRST "started" entry per
skill+session key and treated "completed" as a set, so any key where one
run completed and another dangles was never repaired — and keys are not
unique per run: legacy entries with no session field all share the
bare-skill key, and the preamble's "$$-epoch" session ids collide within
the same second. One old completion masked every future dangler forever.

The hook now counts started vs completed per key and appends completions
for the DIFFERENCE. Idempotency holds by construction: the appended
completions balance the counts, so the next Stop appends nothing. Pinned
with the two-runs-one-dangling case plus a re-run no-op assertion; all
existing fail-open cases pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:13:16 -07:00

253 lines
9.4 KiB
TypeScript

/**
* Timeline Stop hook (#2553) — fail-open contract (F5).
*
* The preamble writes event:"started" at every skill start; the completion
* write is end-of-workflow prose and unenforceable, so interrupted sessions
* leaked started > completed forever. The Stop hook closes dangling entries.
*
* Contract under test: ALWAYS exits 0 (corrupt timeline, missing timeline,
* garbage stdin), append-only, and the normal path appends event:"completed"
* with outcome "unknown" + source "stop-hook" for every un-closed "started".
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
const SLUG = 'stop-hook-test-project';
let tmpHome: string;
let projectDir: string;
let timelinePath: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-hook-home-'));
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-hook-proj-'));
fs.mkdirSync(path.join(tmpHome, 'projects', SLUG), { recursive: true });
timelinePath = path.join(tmpHome, 'projects', SLUG, 'timeline.jsonl');
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
});
function runHook(stdin: string): { exitCode: number; stdout: string; stderr: string } {
const r = spawnSync('bash', [HOOK], {
input: stdin,
encoding: 'utf-8',
env: {
...process.env,
GSTACK_HOME: tmpHome,
GSTACK_PROJECT_SLUG: SLUG, // deterministic slug, no git required
},
timeout: 15_000,
});
return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
}
function stopPayload(): string {
return JSON.stringify({
session_id: 'sess-abc',
hook_event_name: 'Stop',
cwd: projectDir,
});
}
function timelineEntries(): any[] {
if (!fs.existsSync(timelinePath)) return [];
return fs
.readFileSync(timelinePath, 'utf-8')
.split('\n')
.filter((l) => l.trim())
.map((l) => {
try {
return JSON.parse(l);
} catch {
return { __corrupt: l };
}
});
}
describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
test('normal path: closes dangling started entries, leaves closed pairs alone', () => {
fs.writeFileSync(
timelinePath,
[
JSON.stringify({ skill: 'review', event: 'started', branch: 'main', session: '11-1' }),
JSON.stringify({ skill: 'ship', event: 'started', session: '22-2' }),
JSON.stringify({ skill: 'ship', event: 'completed', session: '22-2', outcome: 'success' }),
].join('\n') + '\n',
);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
const entries = timelineEntries();
// Append-only: the three originals survive verbatim in order.
expect(entries[0]).toMatchObject({ skill: 'review', event: 'started' });
expect(entries[2]).toMatchObject({ skill: 'ship', event: 'completed', outcome: 'success' });
const repairs = entries.filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0]).toMatchObject({
skill: 'review',
event: 'completed',
outcome: 'unknown',
branch: 'main',
session: '11-1',
});
expect(typeof repairs[0].ts).toBe('string');
});
test('idempotent: a second Stop appends nothing new', () => {
fs.writeFileSync(
timelinePath,
JSON.stringify({ skill: 'qa', event: 'started', session: '33-3' }) + '\n',
);
expect(runHook(stopPayload()).exitCode).toBe(0);
const afterFirst = timelineEntries().length;
expect(runHook(stopPayload()).exitCode).toBe(0);
expect(timelineEntries().length).toBe(afterFirst);
});
test('count semantics: two runs under one key, one completed — the dangler is still repaired', () => {
// Legacy entries carry no session field, so both runs share the same
// skill+session key (same-second "$$-epoch" ids collide the same way).
// With set semantics the first run's completion masked the second run's
// dangler forever; counting closes the difference.
fs.writeFileSync(
timelinePath,
[
JSON.stringify({ skill: 'review', event: 'started' }),
JSON.stringify({ skill: 'review', event: 'completed', outcome: 'success' }),
JSON.stringify({ skill: 'review', event: 'started' }),
].join('\n') + '\n',
);
expect(runHook(stopPayload()).exitCode).toBe(0);
const repairs = timelineEntries().filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0]).toMatchObject({ skill: 'review', event: 'completed', outcome: 'unknown' });
// Idempotent under count semantics too: started=2, completed=2 → no-op.
expect(runHook(stopPayload()).exitCode).toBe(0);
expect(timelineEntries().filter((e) => e.source === 'stop-hook')).toHaveLength(1);
});
test('exit 0 on missing timeline (nothing written, nothing created)', () => {
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.existsSync(timelinePath)).toBe(false);
});
test('exit 0 on a corrupt timeline; corrupt lines are skipped, valid ones still repaired', () => {
fs.writeFileSync(
timelinePath,
[
'this is not json at all {{{',
JSON.stringify({ skill: 'qa', event: 'started', session: '44-4' }),
'{"half": "an object"',
].join('\n') + '\n',
);
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].skill).toBe('qa');
});
test('exit 0 on a FULLY corrupt timeline (no valid entries → no write)', () => {
const garbage = 'garbage\n{{{\n';
fs.writeFileSync(timelinePath, garbage);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.readFileSync(timelinePath, 'utf-8')).toBe(garbage);
});
test('exit 0 on garbage stdin', () => {
fs.writeFileSync(
timelinePath,
JSON.stringify({ skill: 'qa', event: 'started', session: '55-5' }) + '\n',
);
const r = runHook('not json');
expect(r.exitCode).toBe(0);
});
test('exit 0 on empty stdin', () => {
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);
fs.writeFileSync(timelinePath, line + filler.repeat(11));
const sizeBefore = fs.statSync(timelinePath).size;
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.statSync(timelinePath).size).toBe(sizeBefore);
});
});
describe('timeline-stop-hook wiring', () => {
test('setup registers the Stop hook with its own source tag and tears it down on --no-team', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
expect(setup).toContain('--event Stop');
expect(setup).toContain('--source gstack-timeline-stop');
expect(setup).toContain('hosts/claude/hooks/timeline-stop-hook');
// --no-team teardown removes it alongside the plan-tune hooks.
const teardown = setup.slice(setup.indexOf('# Also tear down plan-tune'));
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
});
test('gstack-uninstall removes the Stop hook registration', () => {
const uninstall = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
expect(uninstall).toContain('remove-source --source gstack-timeline-stop');
});
test('the bash shim is fail-open: exits 0 even when bun is unavailable', () => {
const r = spawnSync('bash', [HOOK], {
input: '{}',
encoding: 'utf-8',
env: { HOME: tmpHome, PATH: '/usr/bin:/bin', GSTACK_HOME: tmpHome },
timeout: 15_000,
});
expect(r.status).toBe(0);
});
});