mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 05:56:37 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans 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>
187 lines
13 KiB
TypeScript
187 lines
13 KiB
TypeScript
/**
|
|
* End-to-end unattended runs on the simulated Capy host, driven only through
|
|
* the CLI (plan B1a/B1b/B1c, the wave's mandatory free-tier acceptance): a
|
|
* three-revision run validates with unique ids; phases bound from free-form
|
|
* records with one outside voice unavailable close, answer, export and
|
|
* validate; a run gated on <=1.91.94-style decision ids validates after the
|
|
* load-time migration; run.json records gstack_version, a newer run refuses
|
|
* and an older one migrates forward. One conformance run strings every
|
|
* mechanism together.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture';
|
|
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy';
|
|
|
|
const r = capyRunner();
|
|
afterAll(() => r.cleanup());
|
|
const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
|
|
let n = 0;
|
|
function start(args: string[] = []): { out: string; dir: string; plan: string } {
|
|
const dir = path.join(r.host.base, `e2e${++n}`);
|
|
const plan = writePlan(dir);
|
|
const out = path.join(dir, 'run');
|
|
const res = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
|
|
if (res.status !== 0) throw new Error(`first next failed: ${res.stderr}\n${res.stdout}`);
|
|
return { out, dir, plan };
|
|
}
|
|
function expectUniqueIds(out: string) {
|
|
for (const f of ['decisions.jsonl', 'findings.jsonl', 'tasks.jsonl']) {
|
|
const ids = readJsonl(path.join(out, f)).map(x => x.id);
|
|
expect(new Set(ids).size, f).toBe(ids.length);
|
|
}
|
|
const attempts = readJsonl(path.join(out, 'attempts.jsonl')).filter(e => e.event === 'dispatched').map(e => e.attempt);
|
|
expect(new Set(attempts).size).toBe(attempts.length);
|
|
}
|
|
/** Every completed voice reviewed its phase's snapshot (receipt or attestation), recorded as reviewed_sha256. */
|
|
function expectReviewedHashes(out: string) {
|
|
const run = readRunJson(out);
|
|
for (const p of run.phase_order) for (const voice of ['native', 'outside']) {
|
|
const v = run.phases[p][voice];
|
|
if (v.status !== 'completed') continue;
|
|
expect(v.reviewed_sha256, `${p} ${voice}`).toBe(run.phases[p].snapshot);
|
|
if (v.receipt !== 'attested') expect(fs.readFileSync(path.join(out, v.output), 'utf8')).toContain(`INPUT: ${p} ${run.phases[p].snapshot}`);
|
|
}
|
|
}
|
|
const ok = (res: { status: number | null; stderr: string; stdout: string }) => { expect(res.status, res.stderr + res.stdout).toBe(0); return res; };
|
|
|
|
describe('unattended runs on the Capy host', () => {
|
|
test('a three-revision run driven only through the CLI validates with unique ids and records gstack_version', () => {
|
|
const s = start(['--max-rounds', '3']);
|
|
expect(readRunJson(s.out).gstack_version).toBe(VERSION);
|
|
for (const rev of [1, 2]) {
|
|
phaseVia(r, s.out, 'ceo');
|
|
expect(phaseVia(r, s.out, 'eng').outside.stdout).toContain('status=gate_pending');
|
|
expectReviewedHashes(s.out);
|
|
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', String(rev), '--reply', 'all except p1d'])).stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=revise$/m);
|
|
}
|
|
phaseVia(r, s.out, 'ceo');
|
|
phaseVia(r, s.out, 'eng');
|
|
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
|
ok(r.ap(['export', '--out', s.out]));
|
|
const v = ok(r.validate(s.out));
|
|
expect(v.stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
|
|
expectUniqueIds(s.out);
|
|
expectReviewedHashes(s.out);
|
|
expect(readJsonl(path.join(s.out, 'decisions.jsonl')).filter(d => d.gate_rev === 3).every(d => d.id.includes('-r3-'))).toBe(true);
|
|
});
|
|
|
|
test('free-form records (table, list, No findings.) and one unavailable outside voice reach the gate, answer, export and validate', () => {
|
|
const s = start();
|
|
const run = readRunJson(s.out);
|
|
const sha = run.phases.ceo.snapshot;
|
|
const rec = (name: string, text: string) => { const f = path.join(s.dir, name); fs.writeFileSync(f, text); return f; };
|
|
ok(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--model', 'anthropic/claude-opus-5-5', '--attest', 'Capy subagent read the prompt file', '--attempt', run.phases.ceo.native.attempt,
|
|
'--record', rec('ceo-n.md', '# CEO review\n\n| Severity | Title | File |\n|---|---|---|\n| High | Retry cap is unbounded on restart | lib/worker.ts:10 |\n| Low | Name the env var | |\n')]));
|
|
const closed = ok(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--model', 'openai/gpt-6.1-sol', '--record', rec('ceo-o.md', `INPUT: ceo ${sha}\n\n- [High] Jitter bounds are untested\n- [Medium] Resume path skips the cap\n`)]));
|
|
expect(closed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=both/m);
|
|
const eng = ok(r.ap(['next', '--out', s.out]));
|
|
const engSha = /^PHASE: eng snapshot=([0-9a-f]{64})/m.exec(eng.stdout)![1];
|
|
ok(r.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--model', 'anthropic/claude-opus-5-5', '--record', rec('eng-n.md', `INPUT: eng ${engSha}\n\nRead the whole plan.\n\nNo findings.\n`)]));
|
|
const gated = ok(r.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no cross-family subagent on this host']));
|
|
expect(gated.stdout).toMatch(/^PHASE_CLOSED: eng .*coverage=native_only/m);
|
|
expect(gated.stdout).toContain('status=gate_pending');
|
|
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all'])).stdout).toContain('status=complete');
|
|
ok(r.ap(['export', '--out', s.out]));
|
|
expect(ok(r.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete attested=1$/m);
|
|
expect(fs.readFileSync(path.join(s.out, 'review-record.md'), 'utf8')).toMatch(/attested/);
|
|
expectReviewedHashes(s.out);
|
|
});
|
|
|
|
test('a run gated on <=1.91.94-style decision ids validates after the load-time migration', () => {
|
|
const s = start();
|
|
phaseVia(r, s.out, 'ceo');
|
|
phaseVia(r, s.out, 'eng');
|
|
ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']));
|
|
phaseVia(r, s.out, 'ceo');
|
|
phaseVia(r, s.out, 'eng');
|
|
const file = path.join(s.out, 'decisions.jsonl');
|
|
fs.writeFileSync(file, readJsonl(file).map(d => JSON.stringify({ ...d, id: d.id.replace(/-r\d+-/, '-') })).join('\n') + '\n');
|
|
const manifest = readRunJson(s.out);
|
|
delete manifest.gstack_version;
|
|
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
const res = ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all']));
|
|
expect(res.stdout).toMatch(/^GSTACK_VERSION_CHANGED: run unknown -> /m);
|
|
expect(res.stdout).toContain('status=complete');
|
|
ok(r.ap(['export', '--out', s.out]));
|
|
const v = r.validate(s.out);
|
|
expect(v.stderr).not.toContain('ARTIFACT_DUPLICATE_ID');
|
|
expect(v.stderr).not.toContain('ARTIFACT_DANGLING_REF');
|
|
expect(v.status, v.stderr).toBe(0);
|
|
const migrated = readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'decision_ids_migrated');
|
|
expect(migrated).toHaveLength(1);
|
|
expect(migrated[0].n).toBeGreaterThan(0);
|
|
expect(readRunJson(s.out).gstack_version).toBe(VERSION);
|
|
});
|
|
|
|
test('a run written by a newer gstack refuses; an older one prints GSTACK_VERSION_CHANGED once and moves forward', () => {
|
|
const s = start();
|
|
const set = (v: string) => { const m = readRunJson(s.out); m.gstack_version = v; fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(m, null, 2) + '\n'); };
|
|
set('99.0.0.0');
|
|
const newer = r.ap(['next', '--out', s.out]);
|
|
expect(newer.status).toBe(3);
|
|
expect(newer.stderr).toMatch(/99\.0\.0\.0.*\(GSTACK_VERSION_CHANGED\)$/m);
|
|
set('1.91.22.0');
|
|
const older = ok(r.ap(['next', '--out', s.out]));
|
|
expect(older.stdout).toMatch(new RegExp(`^GSTACK_VERSION_CHANGED: run 1\\.91\\.22\\.0 -> ${VERSION.replace(/\./g, '\\.')}`, 'm'));
|
|
expect(ok(r.ap(['next', '--out', s.out])).stdout).not.toMatch(/GSTACK_VERSION_CHANGED/);
|
|
expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'gstack_version_changed')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('conformance: one run through every mechanism', () => {
|
|
test('canonical and attested binds, an unavailable voice, partial then full replies, PLAN_AMEND_PENDING, revise --incorporates, an interruption, three gate revisions', () => {
|
|
const c: CapyRunner = r;
|
|
const s = start(['--max-rounds', '3']);
|
|
const run = readRunJson(s.out).run;
|
|
// ceo: native canonical (fixture), outside attested record
|
|
ok(c.review(path.join(s.out, 'ceo-native-prompt.md')));
|
|
ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(s.out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']));
|
|
const hand = path.join(s.dir, 'ceo-outside-hand.md');
|
|
fs.writeFileSync(hand, '| Severity | Title |\n|---|---|\n| Medium | Retry storage cost is unpriced |\n');
|
|
ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--record', hand, '--model', 'openai/gpt-6.1-sol', '--attest', 'Capy subagent', '--attempt', readRunJson(s.out).phases.ceo.outside.attempt]));
|
|
// eng: native challenge with changes, outside unavailable
|
|
ok(c.ap(['next', '--out', s.out]));
|
|
ok(c.review(path.join(s.out, 'eng-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'High', title: 'Gate the backoff behind a flag', user_challenge: true, changes: 'scope', fix: 'RETRY_BACKOFF=1' }]) }));
|
|
ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(s.out, 'eng', 'native'), '--model', 'anthropic/claude-opus-5-5']));
|
|
expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending');
|
|
const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id;
|
|
// partial, then full
|
|
expect(ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', `${accept}b`])).stdout).toMatch(/^GATE_PENDING: p1/m);
|
|
const full = ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all']));
|
|
expect(full.stdout).toMatch(new RegExp(`^APPLY: ${run}-${accept} `, 'm'));
|
|
const pendingNext = c.ap(['next', '--out', s.out]).stderr;
|
|
expect(pendingNext).toMatch(/\(PLAN_AMEND_PENDING\)$/m);
|
|
expect(pendingNext).toContain(`; fix: ${RESULT_CODES.PLAN_AMEND_PENDING.fix} (PLAN_AMEND_PENDING)`);
|
|
const v2 = path.join(s.dir, 'plan-v2.md');
|
|
fs.writeFileSync(v2, SMALL_PLAN.replace('Tests cover', 'Ship behind RETRY_BACKOFF=1, default off. Tests cover'));
|
|
expect(ok(c.ap(['revise', '--out', s.out, '--plan', v2, '--incorporates', `${run}-${accept}`])).stdout).toMatch(/^AMENDMENTS: pending=none$/m);
|
|
// interruption: attempts dispatched, the driver dies holding the lock; resume reclaims and the parent redispatches
|
|
ok(c.ap(['next', '--out', s.out]));
|
|
fs.writeFileSync(path.join(s.out, 'run.lock'), JSON.stringify({ pid: 999_999_999, host: require('node:os').hostname(), at: new Date().toISOString() }));
|
|
const resumed = ok(c.ap(['resume', '--out', s.out]));
|
|
expect(resumed.stdout).toMatch(/execution_unknown=2/);
|
|
const unknown = [...resumed.stdout.matchAll(/^EXECUTION_UNKNOWN: (\S+)$/gm)].map(m => m[1]!);
|
|
ok(c.ap(['next', '--out', s.out, ...unknown.flatMap(a => ['--redispatch', a])]));
|
|
for (const voice of ['native', 'outside']) ok(c.review(path.join(s.out, `ceo-${voice}-prompt.md`)));
|
|
for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', resultOf(s.out, 'ceo', voice as 'native' | 'outside'), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol']));
|
|
phaseVia(c, s.out, 'eng');
|
|
expect(readRunJson(s.out).gate_rev).toBe(2);
|
|
ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d']));
|
|
phaseVia(c, s.out, 'ceo');
|
|
phaseVia(c, s.out, 'eng');
|
|
expect(ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
|
ok(c.ap(['export', '--out', s.out]));
|
|
expect(ok(c.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
|
|
const final = readRunJson(s.out);
|
|
expect(final.counts.decisions_pending).toBe(0);
|
|
expect((final.amendments ?? []).filter((a: any) => a.status === 'pending')).toEqual([]);
|
|
expect(final.gate_rev).toBe(3);
|
|
expectUniqueIds(s.out);
|
|
expectReviewedHashes(s.out);
|
|
expect(fs.readFileSync(boundOf(s.out, 'ceo', 'native'), 'utf8')).toContain(final.phases.eng.snapshot);
|
|
});
|
|
});
|