mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
219 lines
13 KiB
TypeScript
219 lines
13 KiB
TypeScript
/** Free real-Bun/PTY regression: the counting body must settle before its equal outer timeout. */
|
||
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 { execFileSync } from 'node:child_process';
|
||
|
||
const ROOT = path.resolve(import.meta.dir, '..');
|
||
type Event = { event: string; at: number; invocation: number; pid?: number; start?: string; cwd?: string; data?: string; elapsed?: number; outcome?: string; fixtureGone?: boolean };
|
||
|
||
function ownedFake(event: Event, fake: string): boolean {
|
||
if (!event.pid) return false;
|
||
try {
|
||
if (process.platform === 'linux') {
|
||
const args = fs.readFileSync(`/proc/${event.pid}/cmdline`, 'utf8').split('\0');
|
||
const start = fs.readFileSync(`/proc/${event.pid}/stat`, 'utf8').split(') ')[1]!.split(' ')[19];
|
||
return args.includes(fake) && start === event.start;
|
||
}
|
||
return execFileSync('ps', ['-p', String(event.pid), '-o', 'command='], { encoding: 'utf8', timeout: 5000 }).includes(fake);
|
||
} catch { return false; }
|
||
}
|
||
|
||
test.skipIf(process.platform === 'win32')('count timeout settles and cleans its real PTY before Bun retries the equal outer limit', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-timeout-'));
|
||
const fake = path.join(dir, 'fake-claude');
|
||
const worker = path.join(dir, 'worker.test.ts');
|
||
const eventsPath = path.join(dir, 'events.jsonl');
|
||
const fixtureTmp = path.join(dir, 'tmp');
|
||
fs.mkdirSync(fixtureTmp);
|
||
const helper = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
|
||
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
|
||
import * as fs from 'node:fs';
|
||
const invocation = Number(process.env.TIMEOUT_INVOCATION);
|
||
const log = (event, extra = {}) => fs.appendFileSync(process.env.TIMEOUT_EVENTS, JSON.stringify({event, at:Date.now(), invocation, ...extra})+'\n');
|
||
const start = process.platform === 'linux' ? fs.readFileSync('/proc/self/stat','utf8').split(') ')[1].split(' ')[19] : '';
|
||
log('ready', {pid:process.pid, start, cwd:process.cwd()});
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
process.stdin.on('data', bytes => log('input', {data:bytes.toString()}));
|
||
process.on('SIGINT', () => log('sigint')); // exercise the owned forced-exit fallback
|
||
process.stdout.write('COUNT_TIMEOUT_FIXTURE_READY\n');
|
||
setInterval(() => {}, 1000);
|
||
`, { mode: 0o755 });
|
||
fs.writeFileSync(worker, `import {test} from 'bun:test';\nimport * as fs from 'node:fs';\nimport {runPlanSkillCounting} from ${JSON.stringify(helper)};\n` + String.raw`
|
||
let starts = 0;
|
||
const log = (event, invocation, extra={}) => fs.appendFileSync(process.env.TIMEOUT_EVENTS, JSON.stringify({event,at:Date.now(),invocation,...extra})+'\n');
|
||
test('owned counting timeout', async () => {
|
||
const invocation = ++starts;
|
||
const start = Date.now();
|
||
log('body', invocation);
|
||
try {
|
||
const observation = await runPlanSkillCounting({skillName:'plan-design-review', slashCommand:'/plan-design-review',
|
||
followUpPrompt:'# Timeout lifecycle fixture\nReview this plan.', isLastStep0AUQ:()=>false,
|
||
reviewCountCeiling:8, timeoutMs:8000, startupReadyMarker:'COUNT_TIMEOUT_FIXTURE_READY',
|
||
env:{TIMEOUT_INVOCATION:String(invocation),TIMEOUT_EVENTS:process.env.TIMEOUT_EVENTS}});
|
||
const ready = fs.readFileSync(process.env.TIMEOUT_EVENTS,'utf8').trim().split('\n').map(line=>JSON.parse(line)).find(e=>e.event==='ready'&&e.invocation===invocation);
|
||
log('returned', invocation, {elapsed:Date.now()-start,outcome:observation.outcome,fixtureGone:!fs.existsSync(ready.cwd)});
|
||
throw new Error('HELPER_TIMEOUT_'+invocation);
|
||
} finally { log('finally', invocation); }
|
||
}, 8000);
|
||
`);
|
||
let rows: Event[] = [];
|
||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||
let output = '', error = '';
|
||
let status: number | undefined;
|
||
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
child = Bun.spawn([process.execPath, 'test', '--retry', '1', worker], {
|
||
cwd: ROOT, env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1', EVALS_RUN_ID: '',
|
||
EVALS: '', GSTACK_EVAL_DIR: path.join(dir, 'evals'), TIMEOUT_EVENTS: eventsPath, TMPDIR: fixtureTmp },
|
||
stdout: 'pipe', stderr: 'pipe',
|
||
});
|
||
watchdog = setTimeout(() => child?.kill('SIGKILL'), 42000);
|
||
[status, output, error] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||
rows = fs.readFileSync(eventsPath, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||
expect(status, output + error).toBe(1); // each retry retains its explicit timeout failure
|
||
expect(error).toContain('HELPER_TIMEOUT_1');
|
||
expect(error).toContain('HELPER_TIMEOUT_2');
|
||
const starts = rows.filter(e => e.event === 'body');
|
||
const ready = rows.filter(e => e.event === 'ready');
|
||
const returned = rows.filter(e => e.event === 'returned');
|
||
const finished = rows.filter(e => e.event === 'finally');
|
||
expect(starts).toHaveLength(2);
|
||
expect(ready).toHaveLength(2);
|
||
expect(returned).toHaveLength(2);
|
||
expect(finished).toHaveLength(2);
|
||
expect(ready[0]!.pid).not.toBe(ready[1]!.pid);
|
||
expect(ready[0]!.cwd).not.toBe(ready[1]!.cwd);
|
||
expect(finished[0]!.at).toBeLessThanOrEqual(starts[1]!.at);
|
||
for (const event of returned) {
|
||
expect(event.outcome).toBe('timeout');
|
||
expect(event.elapsed).toBeLessThan(8000);
|
||
expect(event.fixtureGone).toBe(true);
|
||
}
|
||
for (const event of ready) {
|
||
expect(ownedFake(event, fake)).toBe(false);
|
||
const body = starts.find(e => e.invocation === event.invocation)!;
|
||
const inputs = rows.filter(e => e.event === 'input' && e.invocation === event.invocation);
|
||
expect(inputs.map(e => e.data).join('')).toBe('/plan-design-review\r');
|
||
expect(inputs.every(e => e.at - body.at < 3000)).toBe(true);
|
||
}
|
||
} finally {
|
||
if (watchdog) clearTimeout(watchdog);
|
||
if (child && child.exitCode === null) { child.kill('SIGKILL'); await child.exited; }
|
||
if (fs.existsSync(eventsPath)) rows = fs.readFileSync(eventsPath, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||
const cleanup: object[] = [];
|
||
for (const event of rows.filter(e => e.event === 'ready')) {
|
||
const owned = ownedFake(event, fake);
|
||
if (owned) { process.kill(event.pid!, 'SIGKILL'); }
|
||
cleanup.push({pid:event.pid, start:event.start, wasOwned:owned, signal:owned?'SIGKILL':null});
|
||
}
|
||
// Optional immutable owner proof; it never changes the test's assertions.
|
||
if (process.env.PLAN_COUNT_TIMEOUT_PROOF_DIR) {
|
||
const dest = process.env.PLAN_COUNT_TIMEOUT_PROOF_DIR;
|
||
fs.mkdirSync(dest, { recursive: false });
|
||
for (const name of ['events.jsonl','worker.test.ts','fake-claude']) if (fs.existsSync(path.join(dir,name))) fs.copyFileSync(path.join(dir,name),path.join(dest,name));
|
||
fs.writeFileSync(path.join(dest,'worker.log'), output + error);
|
||
fs.writeFileSync(path.join(dest,'result.json'), JSON.stringify({status,cleanup,rows},null,2)+'\n');
|
||
}
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}, 48000);
|
||
|
||
test.skipIf(process.platform === 'win32')('deadline boundaries stop boot, late screen results and delayed Enter without abandoning cleanup', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-deadline-'));
|
||
const fake = path.join(dir, 'fake-claude');
|
||
const worker = path.join(dir, 'worker.ts');
|
||
const helper = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
|
||
const screenModule = pathToFileURL(path.join(ROOT, 'test/helpers/pty-screen.ts')).href;
|
||
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
|
||
import * as fs from 'node:fs';
|
||
const log = (event, extra={}) => fs.appendFileSync(process.env.BOUNDARY_EVENTS,JSON.stringify({event,at:Date.now(),invocation:1,...extra})+'\n');
|
||
const start = process.platform === 'linux' ? fs.readFileSync('/proc/self/stat','utf8').split(') ')[1].split(' ')[19] : '';
|
||
log('ready',{pid:process.pid,start,cwd:process.cwd()});
|
||
process.stdin.setRawMode(true); process.stdin.resume();
|
||
process.stdin.on('data',data=>{
|
||
log('input',{data:data.toString()});
|
||
process.stdout.write('\x1b[2J\x1b[HWhich remedy should be used?\r\n❯1.First remedy\r\n2.Second remedy\r\n');
|
||
});
|
||
process.on('SIGINT',()=>{log('sigint');process.exit(0)});
|
||
process.stdout.write('COUNT_BOUNDARY_FIXTURE_READY\n');
|
||
setInterval(()=>{},1000);
|
||
`, { mode: 0o755 });
|
||
fs.writeFileSync(worker, `import {mock} from 'bun:test';\nimport * as fs from 'node:fs';\n` +
|
||
`const screenModule=${JSON.stringify(screenModule)};\nconst originalScreen=(await import(screenModule)).createPtyScreen;\n` + String.raw`
|
||
const mode=process.env.BOUNDARY_MODE;
|
||
let start=0, reads=0;
|
||
const log=(event,extra={})=>fs.appendFileSync(process.env.BOUNDARY_EVENTS,JSON.stringify({event,at:Date.now(),invocation:1,...extra})+'\n');
|
||
if(mode==='boot') {
|
||
const sleep=Bun.sleep.bind(Bun);
|
||
Bun.sleep=async ms=>{
|
||
if(typeof ms==='number' && ms>250 && ms<1000) {
|
||
log('early-clipped-wake',{requested:ms});
|
||
return sleep(Math.max(0,ms-250));
|
||
}
|
||
return sleep(ms);
|
||
};
|
||
}
|
||
if(mode==='screen') mock.module(screenModule,()=>({createPtyScreen:async(...args)=>{
|
||
const screen=await originalScreen(...args);
|
||
return {...screen,read:async()=>{reads++; await Bun.sleep(Math.max(0,start+3200-Date.now()));return screen.read();}};
|
||
}}));
|
||
` + `const {runPlanSkillCounting}=await import(${JSON.stringify(helper)});\n` + String.raw`
|
||
start=Date.now();log('body');
|
||
const observation=await runPlanSkillCounting({skillName:'plan-design-review',slashCommand:'/plan-design-review',
|
||
followUpPrompt:'Review the deadline fixture.',isLastStep0AUQ:()=>false,reviewCountCeiling:8,timeoutMs:mode==='boot'?6000:8000,
|
||
...(mode==='boot'?{}:{startupReadyMarker:'COUNT_BOUNDARY_FIXTURE_READY'}),
|
||
pickAUQ:(_routing,_active,context)=>{
|
||
const ready=fs.readFileSync(process.env.BOUNDARY_EVENTS,'utf8').trim().split('\n').map(line=>JSON.parse(line)).find(event=>event.event==='ready');
|
||
if(!Object.isFrozen(context)||context.cwd!==ready.cwd||!Number.isFinite(context.deadlineAt)||context.deadlineAt<=Date.now()||context.deadlineAt>start+8000)
|
||
throw new Error('Picker did not receive its owned fixture and bounded deadline');
|
||
log('picker');while(Date.now()-start<2800){};return 2;
|
||
},
|
||
env:{BOUNDARY_EVENTS:process.env.BOUNDARY_EVENTS}});
|
||
const events=fs.readFileSync(process.env.BOUNDARY_EVENTS,'utf8').trim().split('\n').map(line=>JSON.parse(line));
|
||
log('returned',{outcome:observation.outcome,elapsed:Date.now()-start,reads,fixtureGone:!fs.existsSync(events.find(e=>e.event==='ready').cwd)});
|
||
`);
|
||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
try {
|
||
const results = await Promise.allSettled(['boot','screen','enter'].map(async mode => {
|
||
const eventPath=path.join(dir,mode+'.jsonl');
|
||
const fixtureTmp=path.join(dir,mode); fs.mkdirSync(fixtureTmp);
|
||
const child=Bun.spawn([process.execPath,worker], {cwd:ROOT,
|
||
env:{...process.env,BROWSE_TERMINAL_BINARY:fake,EVALS_HERMETIC:'1',EVALS:'',EVALS_RUN_ID:'',
|
||
GSTACK_EVAL_DIR:path.join(dir,'evals-'+mode),TMPDIR:fixtureTmp,BOUNDARY_MODE:mode,BOUNDARY_EVENTS:eventPath},stdout:'pipe',stderr:'pipe'});
|
||
children.push(child);timers.push(setTimeout(()=>child.kill('SIGKILL'),25000));
|
||
const [code,out,err]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
|
||
expect(code,out+err).toBe(0);
|
||
const events=fs.readFileSync(eventPath,'utf8').trim().split('\n').map(line=>JSON.parse(line));
|
||
const returned=events.find(e=>e.event==='returned');
|
||
expect(returned.outcome).toBe('timeout'); expect(returned.fixtureGone).toBe(true);
|
||
expect(returned.elapsed).toBeLessThan(mode==='boot'?6000:8000);
|
||
const input=events.filter(e=>e.event==='input').map(e=>e.data).join('');
|
||
expect(input).toBe(mode==='boot'?'':mode==='screen'?'/plan-design-review\r':'/plan-design-review\r2');
|
||
if(mode==='boot') expect(events.some(e=>e.event==='early-clipped-wake')).toBe(true);
|
||
if(mode==='screen') { expect(returned.reads).toBe(1);expect(events.some(e=>e.event==='picker')).toBe(false); }
|
||
expect(ownedFake(events.find(e=>e.event==='ready'),fake)).toBe(false);
|
||
}));
|
||
for (const result of results) if (result.status === 'rejected') throw result.reason;
|
||
} finally {
|
||
timers.forEach(clearTimeout);
|
||
for(const child of children) if(child.exitCode===null){child.kill('SIGKILL');await child.exited;}
|
||
for(const mode of ['boot','screen','enter']) {
|
||
const eventPath=path.join(dir,mode+'.jsonl');
|
||
if(!fs.existsSync(eventPath))continue;
|
||
for(const event of fs.readFileSync(eventPath,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line))) {
|
||
if(event.event==='ready'&&ownedFake(event,fake))process.kill(event.pid,'SIGKILL');
|
||
}
|
||
}
|
||
if(process.env.PLAN_COUNT_TIMEOUT_PROOF_DIR) {
|
||
const dest=process.env.PLAN_COUNT_TIMEOUT_PROOF_DIR+'-boundaries';fs.mkdirSync(dest,{recursive:false});
|
||
for(const name of ['worker.ts','fake-claude','boot.jsonl','screen.jsonl','enter.jsonl'])if(fs.existsSync(path.join(dir,name)))fs.copyFileSync(path.join(dir,name),path.join(dest,name));
|
||
}
|
||
fs.rmSync(dir,{recursive:true,force:true});
|
||
}
|
||
},30000);
|