Files
gstack/test/autoplan-durability.test.ts
Garry Tanandcapy-ai[bot] c71d764ba4 v1.91.115.0 feat: field list 2 wave A: autoplan round cap and public export, eval-plan diagnostics and model rules, --for review-voice (#3234)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

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

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

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

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

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

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

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

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

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

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

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

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

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

* docs(lease): name the double-crash limit of breaking a dead lock

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

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

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

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

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

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

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

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

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

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

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

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

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

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

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

* docs: changelog entry for field list 2 wave A

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

* chore: stamp v1.91.115.0

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-17 02:40:45 +00:00

127 lines
7.4 KiB
TypeScript

/**
* Field list 2, W7: lane durability for unattended /autoplan on the simulated
* Capy host. With GSTACK_AUTOPLAN_CHECKPOINT_DIR unset the runner prints the
* export line naming the private project-user Drive folder and writes nothing
* there; `gstack-config set autoplan_checkpoint drive` opts in. A generation is
* published after each bound voice and phase transition, and a run restored
* from one never redispatches a voice it had bound. The coordinator contract
* and /ship's handoff carry the WIP-push rule. Every spawn has a timeout.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { capyDriveFolder } from '../lib/autoplan-checkpoint';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { ROOT } from './helpers/autoplan-run-fixture';
import { capyRunner, readRunJson, writePlan } from './helpers/autoplan-capy';
const r = capyRunner();
afterAll(() => r.cleanup());
const drive = path.join(r.host.home, '.capy', 'drive', 'project-user-demo-garry');
fs.mkdirSync(drive, { recursive: true });
const folder = path.join(drive, 'autoplan-checkpoints');
const config = (value: string) => spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-config'), 'set', 'autoplan_checkpoint', value], { env: r.env, encoding: 'utf8', timeout: 30_000 });
let n = 0;
const start = () => { const dir = path.join(r.host.base, `dur${++n}`); const out = path.join(dir, 'run'); return { dir, out, first: r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']) }; };
describe('checkpoints on Capy', () => {
test('unset: the first next prints the export line for the project-user Drive folder and writes nothing there', () => {
const { first } = start();
expect(first.status, first.stderr).toBe(0);
expect(first.stderr).toContain(`CHECKPOINT: unset; to survive machine replacement: export GSTACK_AUTOPLAN_CHECKPOINT_DIR=${folder} (or for every run: gstack-config set autoplan_checkpoint drive)`);
expect(fs.existsSync(folder)).toBe(false);
});
test('opted in: a generation after each bound voice and transition; a restored run keeps the bound voice and redispatches only the other', () => {
expect(config('drive').status).toBe(0);
try {
const { dir, out, first } = start();
expect(first.status, first.stderr).toBe(0);
expect(first.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result /m);
const run = readRunJson(out);
const collection = path.join(folder, run.run);
expect(fs.existsSync(path.join(collection, 'current.json'))).toBe(true);
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: '[]' }).status).toBe(0);
const bound = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]);
expect(bound.status, bound.stderr).toBe(0);
expect(bound.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result gate_rev=0 revision=\d+ /m);
const restored = path.join(dir, 'restored');
const resume = r.ap(['resume', '--out', restored, '--from', collection]);
expect(resume.status, resume.stderr).toBe(0);
expect(resume.stdout).toMatch(/^EXECUTION_UNKNOWN: \S+-a2$/m);
const back = readRunJson(restored);
expect(back.phases.ceo.native.status).toBe('completed');
const refused = r.ap(['next', '--out', restored]);
expect(refused.status).toBe(3);
expect(refused.stderr).toMatch(/\S+-a2 \(ceo outside\).*\(EXECUTION_UNKNOWN\)$/m);
const again = r.ap(['next', '--out', restored, '--redispatch', back.phases.ceo.outside.attempt]);
expect(again.status, again.stderr).toBe(0);
expect(again.stdout.match(/^ATTEMPT: /gm)).toHaveLength(1);
expect(again.stdout).toMatch(/^ATTEMPT: \S+-a3 phase=ceo voice=outside /m);
expect(readRunJson(restored).phases.ceo.native.attempt).toBe(run.phases.ceo.native.attempt);
} finally {
config('off');
}
});
test('opted in: the next that opens a phase mid-run publishes a generation', () => {
expect(config('drive').status).toBe(0);
try {
const { out, first } = start();
expect(first.status, first.stderr).toBe(0);
const run = readRunJson(out);
for (const voice of ['native', 'outside'] as const) {
expect(r.review(path.join(out, `ceo-${voice}-prompt.md`), { FIXTURE_FINDINGS: '[]' }).status).toBe(0);
const bound = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', voice, '--result', run.phases.ceo[voice].attempts[0].result, '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol', '--attempt', run.phases.ceo[voice].attempt]);
expect(bound.status, bound.stderr).toBe(0);
}
const current = () => JSON.parse(fs.readFileSync(path.join(folder, run.run, 'current.json'), 'utf8'));
const before = current().generation;
const opened = r.ap(['next', '--out', out]);
expect(opened.status, opened.stderr).toBe(0);
expect(opened.stdout).toMatch(/^ATTEMPT: \S+ phase=eng voice=native /m);
expect(opened.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result /m);
expect(current()).toMatchObject({ status: 'awaiting_result' });
expect(current().generation).not.toBe(before);
} finally {
config('off');
}
});
test('GSTACK_AUTOPLAN_CHECKPOINT_DIR wins over the Drive opt-in', () => {
expect(config('drive').status).toBe(0);
try {
const elsewhere = path.join(r.host.base, 'explicit-checkpoints');
const dir = path.join(r.host.base, `dur${++n}`);
const first = r.ap(['next', '--out', path.join(dir, 'run'), '--plan', writePlan(dir), '--no-ui'], { GSTACK_AUTOPLAN_CHECKPOINT_DIR: elsewhere });
expect(first.status, first.stderr).toBe(0);
expect(first.stderr).toContain(`CHECKPOINT: ${elsewhere}`);
} finally {
config('off');
}
});
});
describe('capyDriveFolder', () => {
test('one project-user folder, or the one named for the repo among several; none off Capy or when ambiguous', () => {
const home = path.join(r.host.base, 'drive-home');
const env = { GSTACK_CAPY: '1', HOME: home };
const base = path.join(home, '.capy', 'drive');
fs.mkdirSync(path.join(base, 'project-user-gstack-garry'), { recursive: true });
expect(capyDriveFolder(env, 'gstack')).toBe(path.join(base, 'project-user-gstack-garry', 'autoplan-checkpoints'));
expect(capyDriveFolder({ HOME: home }, 'gstack')).toBeUndefined();
fs.mkdirSync(path.join(base, 'project-user-gbrain-garry'), { recursive: true });
expect(capyDriveFolder(env, 'gbrain')).toBe(path.join(base, 'project-user-gbrain-garry', 'autoplan-checkpoints'));
expect(capyDriveFolder(env)).toBeUndefined();
});
});
describe('the WIP-push rule', () => {
test('the coordinator contract and the /ship handoff carry it', () => {
expect(COORDINATOR_CONTRACT).toContain('A lane pushes a WIP commit to its branch after each step and gates a pushed SHA.');
expect(fs.readFileSync(path.join(ROOT, 'autoplan', 'DRIVER.md'), 'utf8')).toContain('A lane pushes a WIP commit to its branch after each step and gates a pushed SHA.');
expect(fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'handoff.md'), 'utf8')).toMatch(/### Multi-lane runs\n\nLanes push a WIP commit after each step and gate a pushed SHA\./);
});
});