Files
gstack/test/timeline-stop-hook-slug.test.ts
Garry Tanandcapy-ai[bot] c85dd0a799 v1.91.316.0 ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A) (#3593)
* ship: token cap with real meters and fresh-context delegation (GSTA-152 PR A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (stopped or lost capped run no longer blocks uncapped review starts; short child return reconciles incomplete)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: carry estimate spend across a meter epoch change; calibration docs match their evidence

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: load the committed calibration file by its repo path so pregate maps it to a lane

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship: fix fence placeholders and register claude-usage fixture (GSTA-152 PR A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: run ship-budget and review-log bins through their interpreters so Windows can launch them

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 10:29:23 +00:00

170 lines
6.0 KiB
TypeScript

/**
* Timeline Stop hook slug resolution (#3103).
*
* Through Git Bash on Windows, gstack-slug took 2.2-2.8 s, past the hook's 2 s
* budget, so the synchronous spawn timed out on every Stop and nothing was
* repaired. The hook now reads gstack-slug's versioned cache in-process and,
* on a miss, hands the slow resolve to a detached child under a TTL lock with
* a per-cwd negative cache.
*
* Fixture: a copy of the hook and its imports in a temp tree whose
* bin/gstack-slug is a stub (runBin resolves bin/ from the hook's own
* location). The stub logs each call, sleeps, then prints a slug or fails.
* The hook runs through Bun argv, never as a shebang.
*/
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';
import { slugCacheFile } from '../lib/bin-context';
const ROOT = path.resolve(import.meta.dir, '..');
const HOOK_FILES = [
'hosts/claude/hooks/timeline-stop-hook.ts',
'hosts/claude/hooks/spawn-bin.ts',
'hosts/claude/hooks/hook-log.ts',
'lib/state-root.ts',
'lib/bin-context.ts',
'lib/remote-identity.ts',
'lib/script-invocation.ts',
'lib/bun-isolation.ts',
'lib/result-codes.ts',
'lib/result-codes-release.ts',
'lib/result-codes-capy.ts',
'lib/result-codes-budget.ts',
];
const SLUG = 'stop-hook-slug-test';
const BUDGET_MS = 2000;
let tmp: string;
let tree: string;
let stateRoot: string;
let projectDir: string;
let callLog: string;
function writeStub(body: string): void {
const stub = path.join(tree, 'bin', 'gstack-slug');
fs.mkdirSync(path.dirname(stub), { recursive: true });
fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$PWD" >> ${JSON.stringify(callLog)}\n${body}\n`, { mode: 0o755 });
}
function seedTimeline(): string {
const file = path.join(stateRoot, 'projects', SLUG, 'timeline.jsonl');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ skill: 'review', event: 'started', session: '11-1' }) + '\n');
return file;
}
function repairs(file: string): number {
return fs.readFileSync(file, 'utf-8').split('\n').filter((l) => l.includes('"source":"stop-hook"')).length;
}
function runHook(): { status: number | null; ms: number } {
const env: Record<string, string | undefined> = { ...process.env, GSTACK_STATE_ROOT: stateRoot };
delete env.GSTACK_PROJECT_SLUG;
const t0 = Date.now();
const r = spawnSync(process.execPath, [path.join(tree, 'hosts', 'claude', 'hooks', 'timeline-stop-hook.ts')], {
input: JSON.stringify({ hook_event_name: 'Stop', cwd: projectDir }),
env,
timeout: 15_000,
});
return { status: r.status, ms: Date.now() - t0 };
}
function calls(): number {
return fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8').split('\n').filter(Boolean).length : 0;
}
const lockFile = () => path.join(stateRoot, 'timeline-stop-hook', 'repair.lock');
async function until(cond: () => boolean, timeoutMs = 20_000): Promise<void> {
const end = Date.now() + timeoutMs;
while (!cond()) {
if (Date.now() > end) throw new Error('condition not met before timeout');
await Bun.sleep(100);
}
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-slug-'));
tree = path.join(tmp, 'tree');
stateRoot = path.join(tmp, 'state');
projectDir = path.join(tmp, 'project');
callLog = path.join(tmp, 'slug-calls.log');
fs.mkdirSync(stateRoot);
fs.mkdirSync(projectDir);
for (const rel of HOOK_FILES) {
fs.mkdirSync(path.dirname(path.join(tree, rel)), { recursive: true });
fs.copyFileSync(path.join(ROOT, rel), path.join(tree, rel));
}
});
afterEach(async () => {
await until(() => !fs.existsSync(lockFile())).catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('timeline-stop-hook slug resolution (#3103)', () => {
test('cache hit: repairs in the foreground without spawning gstack-slug', () => {
writeStub('exit 1');
const timeline = seedTimeline();
const cache = slugCacheFile(stateRoot, projectDir);
fs.mkdirSync(path.dirname(cache), { recursive: true });
fs.writeFileSync(cache, `v2:${SLUG}`);
expect(runHook().status).toBe(0);
expect(repairs(timeline)).toBe(1);
expect(calls()).toBe(0);
expect(fs.existsSync(lockFile())).toBe(false);
});
test('cache miss with a slow gstack-slug: the hook returns within its budget and a background child repairs', async () => {
writeStub(`sleep 3\necho "SLUG=${SLUG}"\necho "BRANCH=main"`);
const timeline = seedTimeline();
const r = runHook();
expect(r.status).toBe(0);
expect(r.ms).toBeLessThan(BUDGET_MS);
expect(repairs(timeline)).toBe(0);
await until(() => repairs(timeline) === 1);
await until(() => !fs.existsSync(lockFile()));
expect(calls()).toBe(1);
expect(repairs(timeline)).toBe(1);
}, 30_000);
test('negative cache: a directory gstack-slug cannot resolve spawns one child, not one per turn', async () => {
writeStub('exit 1');
seedTimeline();
expect(runHook().status).toBe(0);
await until(() => calls() === 1 && !fs.existsSync(lockFile()));
expect(fs.readFileSync(path.join(stateRoot, 'hook-errors.log'), 'utf-8')).toContain('could not resolve project slug');
expect(runHook().status).toBe(0);
expect(runHook().status).toBe(0);
await Bun.sleep(1000);
expect(calls()).toBe(1);
}, 30_000);
test('a fresh lock defers the repair; a lock past its TTL is taken over', () => {
writeStub('exit 1');
const timeline = seedTimeline();
const cache = slugCacheFile(stateRoot, projectDir);
fs.mkdirSync(path.dirname(cache), { recursive: true });
fs.writeFileSync(cache, `v2:${SLUG}`);
fs.mkdirSync(path.dirname(lockFile()), { recursive: true });
fs.writeFileSync(lockFile(), '999999\n');
expect(runHook().status).toBe(0);
expect(repairs(timeline)).toBe(0);
const old = new Date(Date.now() - 120_000);
fs.utimesSync(lockFile(), old, old);
expect(runHook().status).toBe(0);
expect(repairs(timeline)).toBe(1);
expect(fs.existsSync(lockFile())).toBe(false);
});
});