mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
245 lines
14 KiB
TypeScript
245 lines
14 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const terminal = `class {
|
|
unicode = { register() {}, activeVersion: '' };
|
|
buffer = { active: { baseY: 0, getLine: () => ({ getCell: () => undefined, translateToString: () => 'partial viewport' }) } };
|
|
write(text, done) {
|
|
controls.writes++;
|
|
if (text.includes('reject')) throw controls.original;
|
|
if (text.includes('stall')) { controls.callback = done; return; }
|
|
done(); done();
|
|
}
|
|
dispose() { controls.disposals++; if (controls.disposeThrows) throw new Error('secondary disposal failure'); }
|
|
}`;
|
|
|
|
function adapter() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-drain-'));
|
|
const screenModule = path.join(ROOT, 'test/helpers/pty/screen.ts');
|
|
const fixtureModule = path.join(ROOT, 'test/helpers/plan-count-fixture.ts');
|
|
const screen = path.join(dir, 'screen.ts');
|
|
const runner = path.join(dir, 'runner.ts');
|
|
fs.writeFileSync(screen, fs.readFileSync(screenModule, 'utf8')
|
|
.replace('const Terminal = await loadTerminal();', `const Terminal = ${terminal};`)
|
|
.replace(/from (['"])(\.\.?\/[^'"]+)\1/g, (_match, _quote, relative) =>
|
|
'from ' + JSON.stringify(pathToFileURL(path.resolve(path.dirname(screenModule), relative + '.ts')).href)) +
|
|
`\nexport const controls = { writes: 0, disposals: 0, callback: undefined, disposeThrows: false, original: new Error('original parser rejection') };\n`);
|
|
// The real harness, with the stub viewport in place of pty/screen.ts and a
|
|
// hook that observes each fixture just before the runner cleans it up.
|
|
fs.writeFileSync(runner, `import {mock} from 'bun:test';
|
|
const screen = await import(${JSON.stringify(pathToFileURL(screen).href)});
|
|
mock.module(${JSON.stringify(screenModule)}, () => screen);
|
|
const fixtures = { ...await import(${JSON.stringify(pathToFileURL(fixtureModule).href)}) };
|
|
mock.module(${JSON.stringify(fixtureModule)}, () => ({ ...fixtures, createPlanCountFixture: (...args) => {
|
|
const fixture = fixtures.createPlanCountFixture(...args), cleanup = fixture.cleanup;
|
|
fixture.cleanup = () => { globalThis.beforeFixtureCleanup?.(fixture); cleanup(); };
|
|
return fixture;
|
|
} }));
|
|
export const { launchClaudePty, runPlanSkillCounting, runPlanSkillFloorCheck } =
|
|
await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href)});
|
|
`);
|
|
return { dir, screen: pathToFileURL(screen).href, runner: pathToFileURL(runner).href };
|
|
}
|
|
|
|
test('actual write callbacks settle once; stalled reads and disposal reject at the original absolute deadline', async () => {
|
|
const a = adapter();
|
|
try {
|
|
const { createPtyScreen, controls } = await import(a.screen);
|
|
const deadlineAt = performance.now() + 80;
|
|
const screen = await createPtyScreen(10, 2, { deadlineAt });
|
|
screen.write('complete');
|
|
expect((await screen.readFrame()).inputOffset).toBe(8);
|
|
screen.write('stall');
|
|
const reads = [screen.read(), screen.readFrame()];
|
|
const closing = screen.dispose();
|
|
expect(screen.dispose()).toBe(closing);
|
|
const settled = await Promise.allSettled([...reads, closing]);
|
|
expect(settled.every(result => result.status === 'rejected')).toBe(true);
|
|
expect(new Set(settled.map(result => (result as PromiseRejectedResult).reason)).size).toBe(1);
|
|
expect(performance.now()).toBeLessThan(deadlineAt + 500);
|
|
expect(controls.disposals).toBe(1);
|
|
controls.callback(); controls.callback();
|
|
await expect(screen.readFrame()).rejects.toThrow('viewport is incomplete');
|
|
await expect(screen.dispose()).rejects.toThrow('viewport is incomplete');
|
|
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('cancellation and parser rejection preserve the first failure through repeated disposal', async () => {
|
|
const a = adapter();
|
|
try {
|
|
const { createPtyScreen, controls } = await import(a.screen);
|
|
const abort = new AbortController();
|
|
const screen = await createPtyScreen(10, 2, { deadlineAt: performance.now() + 600_000, signal: abort.signal });
|
|
screen.write('stall');
|
|
const read = screen.readFrame();
|
|
abort.abort(controls.original);
|
|
const error = await read.catch((error: Error) => error);
|
|
expect(error.cause).toBe(controls.original);
|
|
await expect(screen.dispose()).rejects.toBe(error);
|
|
const rejected = await createPtyScreen(10, 2);
|
|
rejected.write('stall'); rejected.write('reject');
|
|
controls.disposeThrows = true;
|
|
const failure = await rejected.readFrame().catch((error: Error) => error);
|
|
expect(failure.cause).toBe(controls.original);
|
|
await expect(rejected.dispose()).rejects.toBe(failure);
|
|
await expect(rejected.dispose()).rejects.toBe(failure);
|
|
expect(controls.disposals).toBe(2);
|
|
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('actual PTY close bounds live, already-exited, wall, spawn-failure and unresponsive-child paths', () => {
|
|
const a = adapter();
|
|
const worker = path.join(a.dir, 'worker.ts');
|
|
try {
|
|
fs.writeFileSync(worker, `import {launchClaudePty} from ${JSON.stringify(a.runner)};
|
|
import {controls} from ${JSON.stringify(a.screen)};
|
|
const realSpawn = Bun.spawn;
|
|
for (const mode of ['live','exited','wall','unresponsive','spawn-failure']) {
|
|
let resolveExit; const signals=[];
|
|
Bun.spawn = (_args, opts) => {
|
|
opts.terminal.data(null,Buffer.from('stall retained raw prefix'));
|
|
if(mode==='spawn-failure') throw controls.original;
|
|
return {pid:123,exited:mode==='exited'?Promise.resolve(0):new Promise(resolve=>resolveExit=resolve),
|
|
kill:signal=>{signals.push(signal);if(mode!=='unresponsive')resolveExit(0);},terminal:{write(){}}};
|
|
};
|
|
const before=performance.now(), disposed=controls.disposals;
|
|
if(mode==='spawn-failure') {
|
|
const error=await launchClaudePty({observeScreen:true,timeoutMs:600000}).catch(error=>error);
|
|
if(error!==controls.original)throw Error('spawn error replaced');
|
|
} else {
|
|
const session=await launchClaudePty({observeScreen:true,timeoutMs:mode==='wall'?60:600000});
|
|
if(mode==='exited')await Bun.sleep(1);
|
|
if(mode==='wall')await Bun.sleep(80);
|
|
const first=session.close();if(session.close()!==first)throw Error('close promise changed');
|
|
const read=session.currentScreenFrame();
|
|
const results=await Promise.allSettled([first,read,session.close()]);
|
|
if(results.some(result=>result.status!=='rejected'))throw Error('incomplete viewport passed');
|
|
if(!session.rawOutput().includes('retained raw prefix'))throw Error('raw diagnostics lost');
|
|
if(mode==='unresponsive'&&signals.join()!=='SIGINT,SIGKILL')throw Error('process cleanup skipped');
|
|
if(mode==='exited'&&signals.length)throw Error('already-exited child signalled');
|
|
}
|
|
if(controls.disposals!==disposed+1)throw Error('screen disposed more than once');
|
|
if(performance.now()-before>3500)throw Error('cleanup allowance exceeded');
|
|
console.log(mode);
|
|
}
|
|
Bun.spawn=realSpawn;
|
|
`);
|
|
const result = spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 15_000,
|
|
env: { ...process.env, BROWSE_TERMINAL_BINARY: process.execPath, EVALS_HERMETIC: '0' } });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout.trim().split('\n')).toEqual(['live','exited','wall','unresponsive','spawn-failure']);
|
|
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
|
|
}, 20_000);
|
|
|
|
test.each(['counting', 'floor'])('%s attempts retain public evidence before actual cleanup within unchanged clocks', (mode) => {
|
|
const a = adapter();
|
|
const worker = path.join(a.dir, 'budget-worker.ts');
|
|
try {
|
|
const entry = fs.readFileSync(path.join(ROOT, 'test/skill-e2e-plan-design-with-ui.test.ts'), 'utf8');
|
|
expect(entry).toContain('timeoutMs: 600_000 - (Date.now() - startedAt)');
|
|
fs.writeFileSync(worker, `import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import {runPlanSkillCounting,runPlanSkillFloorCheck} from ${JSON.stringify(a.runner)};
|
|
const mode=${JSON.stringify(mode)};
|
|
let clock=0, sequence=0;
|
|
const epoch=Date.now(), realSleep=Bun.sleep.bind(Bun), timers=new Map();
|
|
Object.defineProperty(performance,'now',{value:()=>clock});
|
|
Date.now=()=>epoch+clock;
|
|
globalThis.setTimeout=(callback,ms=0)=>{const id=++sequence;timers.set(id,{at:clock+Math.max(0,ms),callback});return id;};
|
|
globalThis.clearTimeout=id=>timers.delete(id);
|
|
globalThis.setInterval=()=>++sequence;globalThis.clearInterval=()=>{};
|
|
Bun.sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
const attempts=[], base=${JSON.stringify(a.dir)};
|
|
for(let attempt=1;attempt<=2;attempt++) {
|
|
const startedAt=Date.now(), timerCount=timers.size;
|
|
clock+=1700;
|
|
const config=path.join(base,'config-'+attempt), artifacts=path.join(base,'artifacts-'+attempt);
|
|
fs.mkdirSync(path.join(config,'projects','public'),{recursive:true});
|
|
process.env.GSTACK_EVAL_DIR=artifacts;
|
|
let fixtureCwd, captured, cleaned=false, exited=false, signals=[];
|
|
Bun.spawn=(_args,opts)=>{
|
|
fixtureCwd=opts.cwd;
|
|
let resolveExit;
|
|
return {pid:100+attempt,exited:new Promise(resolve=>resolveExit=resolve),
|
|
terminal:{write(input){if(!input.includes('/plan-design-review'))return;
|
|
const question={header:'Layout',question:'Which layout should the UI use?',options:[{label:'One panel'},{label:'Two panels'}]};
|
|
const event={cwd:opts.cwd,sessionId:'public',timestamp:new Date(Date.now()).toISOString(),isSidechain:false,
|
|
message:{role:'assistant',content:[{type:'tool_use',id:'question-'+attempt,name:'AskUserQuestion',input:{questions:[question]}}]}};
|
|
fs.appendFileSync(path.join(config,'projects/public/public.jsonl'),JSON.stringify(event)+'\\n');
|
|
opts.terminal.data(null,Buffer.from('stall public question prefix attempt '+attempt));
|
|
}},kill(signal){signals.push(signal);exited=true;resolveExit(0);}};
|
|
};
|
|
const captures=()=>fs.existsSync(artifacts)?fs.readdirSync(artifacts,{recursive:true}).filter(file=>String(file).endsWith('observation.json')).map(file=>path.join(artifacts,String(file))):[];
|
|
globalThis.beforeFixtureCleanup=fixture=>{
|
|
if(!fs.existsSync(fixture.cwd))throw Error('cleanup preceded capture');
|
|
const files=captures();if(files.length!==1)throw Error('attempt capture missing');
|
|
captured=JSON.parse(fs.readFileSync(files[0],'utf8'));
|
|
if(captured.state!=='threw'||!captured.error.includes('incomplete'))throw Error('failure classification lost');
|
|
if(!captured.publicTools.some(event=>event.toolUseId==='question-'+attempt))throw Error('public question lost');
|
|
if(!fs.readFileSync(path.join(path.dirname(files[0]),'terminal.raw.log'),'utf8').includes('attempt '+attempt))throw Error('raw prefix lost');
|
|
cleaned=true;
|
|
};
|
|
let done=false, error;
|
|
const run=mode==='counting'?runPlanSkillCounting:runPlanSkillFloorCheck;
|
|
const helper=run({skillName:'plan-design-review',slashCommand:'/plan-design-review',
|
|
followUpPrompt:'# UI input',fixtureFiles:{'review-input.md':'# UI plan'},isLastStep0AUQ:()=>false,reviewCountCeiling:1,
|
|
timeoutMs:mode==='counting'?600_000-(Date.now()-startedAt):600_000,env:{CLAUDE_CONFIG_DIR:config}})
|
|
.catch(value=>error=value).finally(()=>done=true);
|
|
for(let steps=0;!done&&steps<2000;steps++) {
|
|
await realSleep(0);
|
|
if(done)break;
|
|
const next=[...timers].sort((a,b)=>a[1].at-b[1].at)[0];
|
|
if(!next)throw Error('helper stalled without a settlement timer');
|
|
timers.delete(next[0]);clock=Math.max(clock,next[1].at);next[1].callback();
|
|
}
|
|
await helper;
|
|
if(!error?.message.includes('viewport is incomplete')||!cleaned||!exited||fs.existsSync(fixtureCwd))throw Error('actual cleanup or original failure lost');
|
|
if(Date.now()-startedAt>(mode==='counting'?600000:660000)||timers.size!==timerCount)throw Error('budget reset or timer leak');
|
|
if(captured.capture.cwd!==fixtureCwd||!captures().length)throw Error('durable attempt identity lost');
|
|
attempts.push({attempt,elapsed:Date.now()-startedAt,signals,fixtureCwd,artifacts});
|
|
}
|
|
if(clock>1800000||attempts[0].fixtureCwd===attempts[1].fixtureCwd)throw Error('file wall or attempt isolation failed');
|
|
console.log(JSON.stringify({attempts,total:clock,fileWall:1800000}));
|
|
`);
|
|
const result = spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 20_000,
|
|
env: { ...process.env, BROWSE_TERMINAL_BINARY: process.execPath, EVALS_HERMETIC: '1', EVALS_RUN_ID: '',
|
|
GSTACK_EVAL_DIR: '', TMPDIR: a.dir } });
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
const proof = JSON.parse(result.stdout.trim().split('\n').at(-1)!);
|
|
expect(proof.attempts).toHaveLength(2);
|
|
expect(proof.attempts.map((attempt: { elapsed: number }) => attempt.elapsed))
|
|
.toEqual(mode === 'counting' ? [595000, 595000] : [609700, 609700]);
|
|
expect(proof.total).toBe(mode === 'counting' ? 1190000 : 1219400);
|
|
expect(proof.total).toBeLessThan(proof.fileWall);
|
|
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
|
|
}, 25_000);
|
|
|
|
// The fake judge binary is a #! shell script, which Windows cannot exec; the PTY judge itself only
|
|
// runs inside the POSIX PTY eval harness, so this case is POSIX-only like the harness it covers.
|
|
test.skipIf(process.platform === 'win32')('the PTY state judge leaves the event loop to concurrent sessions while it waits', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-judge-'));
|
|
const claude = path.join(dir, 'claude');
|
|
const worker = path.join(dir, 'worker.ts');
|
|
try {
|
|
fs.writeFileSync(claude, '#!/bin/sh\ncat >/dev/null\nsleep 1\necho \'{"state":"waiting","reasoning":"fake judge"}\'\n', { mode: 0o755 });
|
|
fs.writeFileSync(worker, `import {judgePtyState} from ${JSON.stringify(pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href)};
|
|
const ticks=[];const timer=setInterval(()=>ticks.push(performance.now()),50);
|
|
const verdict=await judgePtyState('Which layout? 1. One panel 2. Two panels',{testName:'nonblocking'});
|
|
clearInterval(timer);
|
|
console.log(JSON.stringify({state:verdict.state,elapsedMs:verdict.elapsedMs,ticks:ticks.length}));
|
|
`);
|
|
const result = spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 15_000,
|
|
env: { ...process.env, BROWSE_TERMINAL_BINARY: claude, HOME: dir } });
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
const proof = JSON.parse(result.stdout.trim().split('\n').at(-1)!);
|
|
expect(proof.state).toBe('waiting');
|
|
expect(proof.elapsedMs).toBeGreaterThanOrEqual(1000);
|
|
expect(proof.ticks).toBeGreaterThanOrEqual(10);
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
}, 20_000);
|