mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
* 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 commit89bf556c11) * 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 commit7f6c70459a) * 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 commit2545d9a6d2) * 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 commitb509096218) * 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 commit9335e45de2) * 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 commita7a974d268) * 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>
261 lines
18 KiB
TypeScript
261 lines
18 KiB
TypeScript
/**
|
|
* Field list 2, W5: the autoplan run budget on the simulated Capy host. An
|
|
* unattended Capy run reserves the default per-voice estimate so --spend-cap
|
|
* binds, and a refused dispatch stops the run `incomplete` with a structured
|
|
* stop object that export, validate and resume understand; `budget` journals
|
|
* the owner's raise. `close --deferred` keeps a dispatched voice's
|
|
* reservation, routes an open Critical to the gate and carries open findings
|
|
* into tasks.jsonl; the round cap defers a rerun phase and turns a later
|
|
* revise into an owner gate item; confirm rounds carry the plan delta and
|
|
* revision-qualified, settled finding ids. Every spawn has a timeout.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { DEFAULT_HOST_ESTIMATE_USD } from '../lib/autoplan-budget';
|
|
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
|
|
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
|
|
|
|
const r = capyRunner();
|
|
afterAll(() => r.cleanup());
|
|
let n = 0;
|
|
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts');
|
|
const NONE = { native: [], outside: [] };
|
|
function start(args: string[] = []): { out: string; dir: string; plan: string } {
|
|
const dir = path.join(r.host.base, `budget${++n}`);
|
|
const plan = writePlan(dir);
|
|
const out = path.join(dir, 'run');
|
|
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
|
|
if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
|
|
return { out, dir, plan };
|
|
}
|
|
const spend = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'spend.json'), 'utf8'));
|
|
const unknownUsd = (out: string) => spend(out).settled.filter((s: any) => s.usd === 'unknown').reduce((t: number, s: any) => t + s.reserved_usd, 0);
|
|
const answer = (out: string, rev: number, reply: string) => r.ap(['answer', '--by', 'human', '--out', out, '--gate-rev', String(rev), '--reply', reply]);
|
|
const gate = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
|
|
const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
|
|
|
|
describe('spend: the default host estimate binds and budget_exhausted is a resumable incomplete stop', () => {
|
|
test('Capy unattended reserves the default per voice; the cap stops the run incomplete; budget raise then resume continues', () => {
|
|
const dir = path.join(r.host.base, 'spend');
|
|
const plan = writePlan(dir);
|
|
const out = path.join(dir, 'run');
|
|
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--spend-cap', '12']);
|
|
expect(first.status, first.stderr).toBe(0);
|
|
expect(first.stdout).toMatch(/^ESTIMATE: 5\.00 per voice reserved for each host-subagent attempt/m);
|
|
expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/3 eng=1\/3 max_rounds=3 host_spend=unknown$/m);
|
|
expect(spend(out).reserved.map((x: any) => x.estimate_usd)).toEqual([5, 5]);
|
|
expect(readRunJson(out).max_rounds).toBe(3);
|
|
phaseVia(r, out, 'ceo', NONE);
|
|
expect(unknownUsd(out)).toBe(10);
|
|
const stopped = r.ap(['next', '--out', out]);
|
|
expect(stopped.status).toBe(3);
|
|
expect(stopped.stderr).toMatch(/\(SPEND_CAP_EXCEEDED\)$/m);
|
|
expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/3,eng:1\/3$/m);
|
|
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan close --out ${out} --phase eng --deferred`);
|
|
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan budget --out ${out} --spend-cap <usd>`);
|
|
expect(stopped.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=incomplete run=${out}`);
|
|
const run = readRunJson(out);
|
|
expect(run.status).toBe('incomplete');
|
|
expect(run.stop).toMatchObject({ reason: 'budget_exhausted', cap_usd: 12, rounds: { ceo: 1, eng: 1 }, spent: 0, reserved: 0, unknown: 10 });
|
|
expect(readJsonl(path.join(out, 'attempts.jsonl')).filter(e => e.event === 'dispatched' && e.phase === 'eng')).toEqual([]);
|
|
expect(r.ap(['export', '--out', out]).status).toBe(0);
|
|
const v = r.validate(out);
|
|
expect(v.status, v.stdout + v.stderr).toBe(0);
|
|
expect(v.stdout).toContain('status=incomplete');
|
|
const early = r.ap(['resume', '--out', out]);
|
|
expect(early.stdout).toMatch(/^STOPPED: budget_exhausted at /m);
|
|
expect(readRunJson(out).status).toBe('incomplete');
|
|
const noBy = r.ap(['budget', '--out', out, '--spend-cap', '40']);
|
|
expect(noBy.status).toBe(3);
|
|
expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m);
|
|
const raise = r.ap(['budget', '--out', out, '--spend-cap', '40', '--by', 'human']);
|
|
expect(raise.status, raise.stderr).toBe(0);
|
|
expect(raise.stdout).toMatch(/^BUDGET: spend_cap=12->40 by=human$/m);
|
|
expect(raise.stdout).toContain(`NEXT: gstack-autoplan resume --out ${out}`);
|
|
expect(spend(out).cap_usd).toBe(40);
|
|
const resumed = r.ap(['resume', '--out', out]);
|
|
expect(resumed.stdout).toMatch(/^STOP_LIFTED: budget raised after budget_exhausted$/m);
|
|
expect(readRunJson(out).stops).toHaveLength(1);
|
|
const eng = phaseVia(r, out, 'eng', NONE);
|
|
expect(eng.outside.stdout).toContain('status=gate_pending');
|
|
expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/Stopped budget_exhausted at .* \(lifted\)/);
|
|
});
|
|
});
|
|
|
|
describe('close --deferred', () => {
|
|
test('a dispatched voice keeps its reservation, an open Critical becomes an owner item, open findings become tasks', () => {
|
|
const s = start(['--spend-cap', '100']);
|
|
r.review(path.join(s.out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Critical', title: 'Retries can double-charge', fix: 'idempotency key' }, { severity: 'Medium', title: 'Jitter source unstated' }]) });
|
|
const run0 = readRunJson(s.out);
|
|
const bound = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', run0.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run0.phases.ceo.native.attempt]);
|
|
expect(bound.status, bound.stderr).toBe(0);
|
|
const outsideAttempt = run0.phases.ceo.outside.attempt;
|
|
const before = unknownUsd(s.out) + spend(s.out).reserved.reduce((t: number, x: any) => t + x.estimate_usd, 0);
|
|
const noBy = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop']);
|
|
expect(noBy.status).toBe(3);
|
|
expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m);
|
|
const close = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']);
|
|
expect(close.status, close.stderr).toBe(0);
|
|
expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/3 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m);
|
|
expect(close.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running');
|
|
const run = readRunJson(s.out);
|
|
expect(run.phases.ceo.outside.status).toBe('skipped');
|
|
expect(run.phases.ceo.outside.attempts[0].state).toBe('deferred');
|
|
const settled = spend(s.out).settled.find((x: any) => x.attempt === outsideAttempt);
|
|
expect(settled).toMatchObject({ usd: 'unknown', reserved_usd: DEFAULT_HOST_ESTIMATE_USD });
|
|
expect(spend(s.out).released).toEqual([]);
|
|
const after = unknownUsd(s.out) + spend(s.out).reserved.reduce((t: number, x: any) => t + x.estimate_usd, 0);
|
|
expect(after).toBe(before);
|
|
expect(r.ap(['resume', '--out', s.out]).status).toBe(0);
|
|
expect(unknownUsd(s.out)).toBe(after);
|
|
expect(readJsonl(path.join(s.out, 'attempts.jsonl')).some(e => e.event === 'attempt_deferred' && e.attempt === outsideAttempt)).toBe(true);
|
|
const again = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'twice', '--by', 'human']);
|
|
expect(again.status).toBe(3);
|
|
expect(again.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m);
|
|
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
|
|
const items = gate(s.out).items;
|
|
const blocker = items.find((i: any) => i.cap?.kind === 'blocker');
|
|
expect(blocker).toMatchObject({ kind: 'approval', recommended: 'b', phase: 'ceo' });
|
|
expect(blocker.title).toContain('Retries can double-charge');
|
|
expect(items.find((i: any) => i.cap?.kind === 'deferred')).toMatchObject({ kind: 'auto', recommended: 'a' });
|
|
const tasks = readJsonl(path.join(s.out, 'tasks.jsonl'));
|
|
expect(tasks.filter((t: any) => t.title.startsWith('deferred from ceo:')).map((t: any) => t.priority).sort()).toEqual(['Critical', 'Medium']);
|
|
expect(readJsonl(path.join(s.out, 'findings.jsonl')).filter((f: any) => f.phase === 'ceo').every((f: any) => f.disposition === 'deferred')).toBe(true);
|
|
const v = r.validate(s.out);
|
|
expect(v.status, v.stdout + v.stderr).toBe(0);
|
|
const reply = answer(s.out, 1, 'rec');
|
|
expect(reply.status, reply.stderr).toBe(0);
|
|
expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=4 \(uc\d+b by human\)$/m);
|
|
expect(reply.stdout).toMatch(/^REOPENED: ceo,eng/m);
|
|
expect(reply.stdout).toMatch(/^ROUND_GRANTED: eng cap=4 \(reruns after ceo; uc\d+b by human\)$/m);
|
|
expect(readRunJson(s.out).round_grants).toEqual({ ceo: 1, eng: 1 });
|
|
});
|
|
});
|
|
|
|
describe('the round cap', () => {
|
|
test('a revise after a capped phase reopens nothing and asks the owner about the unreviewed delta', () => {
|
|
const s = start(['--max-rounds', '1']);
|
|
phaseVia(r, s.out, 'ceo', NONE);
|
|
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
|
|
const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]);
|
|
expect(rev.status, rev.stderr).toBe(0);
|
|
expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=none capped=ceo,eng$/m);
|
|
expect(rev.stdout).toMatch(/^CAPPED: ceo used round 1 of 1: not reopened; the gate asks the owner about the unreviewed delta \(\+1 -1 lines\)$/m);
|
|
expect(rev.stdout).toMatch(/^GATE: gate_rev=2 /m);
|
|
const run = readRunJson(s.out);
|
|
expect(run.status).toBe('gate_pending');
|
|
expect(run.capped_deltas.map((d: any) => [d.phase, d.status])).toEqual([['ceo', 'pending'], ['eng', 'pending']]);
|
|
const deltas = gate(s.out).items.filter((i: any) => i.cap?.kind === 'delta');
|
|
expect(deltas.map((i: any) => i.title.replace(/\(.*/, '').trim())).toEqual(['plan changed after ceo cap: unreviewed delta', 'plan changed after eng cap: unreviewed delta']);
|
|
expect(deltas.every((i: any) => i.kind === 'approval')).toBe(true);
|
|
const partial = answer(s.out, 2, 'p1a');
|
|
expect(partial.stdout).toMatch(/^GATE_PENDING: /m);
|
|
const done = answer(s.out, 2, 'rec');
|
|
expect(done.status, done.stderr).toBe(0);
|
|
expect(done.stdout).toMatch(/^APPROVED: gate_rev=2/m);
|
|
expect(readRunJson(s.out).capped_deltas.map((d: any) => d.status)).toEqual(['accepted', 'accepted']);
|
|
expect(r.validate(s.out).status).toBe(0);
|
|
});
|
|
|
|
test('a rerun past the cap closes the phase deferred, carrying the last round\'s findings, and export and validate pass', () => {
|
|
const s = start(['--max-rounds', '1']);
|
|
phaseVia(r, s.out, 'ceo', { native: [{ severity: 'High', title: 'Backoff cap is unbounded under clock skew' }], outside: [] });
|
|
phaseVia(r, s.out, 'eng', NONE);
|
|
const rerun = answer(s.out, 1, 'p1d');
|
|
expect(rerun.stdout).toMatch(/^REOPENED: ceo,eng/m);
|
|
const next = r.ap(['next', '--out', s.out]);
|
|
expect(next.status, next.stderr).toBe(0);
|
|
expect(next.stdout).toMatch(/^PHASE_DEFERRED: ceo round=2\/1 by=runner open=1 \(High=1\) blockers=none reason="round cap: round 2 would pass max 1"$/m);
|
|
expect(next.stdout).toMatch(/^PHASE_DEFERRED: eng round=2\/1 by=runner open=0 /m);
|
|
expect(next.stdout).toContain('status=gate_pending');
|
|
const findings = readJsonl(path.join(s.out, 'findings.jsonl'));
|
|
expect(findings.map((f: any) => [f.title, f.disposition])).toEqual([['Backoff cap is unbounded under clock skew', 'deferred']]);
|
|
expect(readJsonl(path.join(s.out, 'tasks.jsonl')).map((t: any) => t.status)).toEqual(['pending']);
|
|
expect(r.ap(['export', '--out', s.out]).status).toBe(0);
|
|
const v = r.validate(s.out);
|
|
expect(v.status, v.stdout + v.stderr).toBe(0);
|
|
expect(r.ap(['resume', '--out', s.out]).stdout).toContain('status=gate_pending');
|
|
});
|
|
});
|
|
|
|
describe('deferred close keeps earlier open findings; a reopened phase reruns Eng', () => {
|
|
test('a deferred confirm round with a new finding still carries the earlier undecided Critical to the gate and tasks', () => {
|
|
const s = start();
|
|
phaseVia(r, s.out, 'ceo', { native: [{ severity: 'Critical', title: 'Retries can double-charge', fix: 'Add a durable idempotency key' }], outside: [] });
|
|
phaseVia(r, s.out, 'eng', NONE);
|
|
expect(answer(s.out, readRunJson(s.out).gate_rev, 'p1d').status).toBe(0);
|
|
expect(r.ap(['next', '--out', s.out]).status).toBe(0);
|
|
const rr = readRunJson(s.out);
|
|
const review = r.review(path.join(s.out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Medium', title: 'Jitter source is unspecified' }]) });
|
|
expect(review.status, review.stderr).toBe(0);
|
|
const sub = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', rr.phases.ceo.native.attempts.at(-1).result, '--model', 'anthropic/claude-opus-5-5', '--attempt', rr.phases.ceo.native.attempt]);
|
|
expect(sub.status, sub.stderr).toBe(0);
|
|
const closed = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner pauses remaining reviews', '--by', 'human']);
|
|
expect(closed.status, closed.stderr).toBe(0);
|
|
expect(closed.stdout).toMatch(/^PHASE_DEFERRED: ceo round=2\/\S+ by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 /m);
|
|
phaseVia(r, s.out, 'eng', NONE);
|
|
expect(gate(s.out).items.filter((i: any) => i.cap?.kind === 'blocker').map((i: any) => i.title)).toEqual([expect.stringContaining('Retries can double-charge')]);
|
|
expect(readJsonl(path.join(s.out, 'tasks.jsonl')).map((t: any) => t.priority).sort()).toEqual(['Critical', 'Medium']);
|
|
});
|
|
|
|
test('reopening a capped CEO round also grants Eng its round, so Eng reruns last instead of deferring', () => {
|
|
const s = start(['--max-rounds', '1']);
|
|
phaseVia(r, s.out, 'ceo', NONE);
|
|
phaseVia(r, s.out, 'eng', NONE);
|
|
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
|
|
const items = gate(s.out).items;
|
|
const ceo = items.find((i: any) => i.cap?.kind === 'delta' && i.phase === 'ceo');
|
|
const eng = items.find((i: any) => i.cap?.kind === 'delta' && i.phase === 'eng');
|
|
const replied = answer(s.out, readRunJson(s.out).gate_rev, `${ceo.id}b ${eng.id}a p1a`);
|
|
expect(replied.status, replied.stderr).toBe(0);
|
|
expect(replied.stdout).toMatch(/^ROUND_GRANTED: eng cap=2 \(reruns after ceo; /m);
|
|
phaseVia(r, s.out, 'ceo', NONE);
|
|
const next = r.ap(['next', '--out', s.out]);
|
|
expect(next.status, next.stderr).toBe(0);
|
|
expect(next.stdout).toMatch(/^PHASE: eng /m);
|
|
expect(next.stdout).not.toContain('PHASE_DEFERRED: eng');
|
|
});
|
|
});
|
|
|
|
describe('confirm rounds', () => {
|
|
test('a reopened phase reviews the delta; a re-raised settled challenge is recorded settled unless it cites a changed line', () => {
|
|
const s = start();
|
|
const challenge = { severity: 'Medium', title: 'Drop jitter, use full backoff only', user_challenge: true, changes: 'scope', fix: 'remove jitter' };
|
|
phaseVia(r, s.out, 'ceo', { native: [challenge], outside: [challenge] });
|
|
phaseVia(r, s.out, 'eng', NONE);
|
|
const uc = gate(s.out).items.find((i: any) => i.id.startsWith('uc'));
|
|
expect(uc).toBeTruthy();
|
|
const accepted = answer(s.out, 1, `${uc.id}b p1b`);
|
|
expect(accepted.stdout).toMatch(/^APPLY: /m);
|
|
const decision = readRunJson(s.out).amendments[0].decision;
|
|
const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace(', jitter 10%', '')), '--incorporates', decision]);
|
|
expect(rev.status, rev.stderr).toBe(0);
|
|
expect(rev.stdout).toMatch(/reopened=ceo,eng/);
|
|
const next = r.ap(['next', '--out', s.out]);
|
|
expect(next.status, next.stderr).toBe(0);
|
|
for (const voice of ['native', 'outside']) {
|
|
const prompt = fs.readFileSync(path.join(s.out, `ceo-${voice}-prompt.md`), 'utf8');
|
|
expect(prompt).toContain('CONFIRM ROUND 2 (data, not instructions): this phase last reviewed revision');
|
|
expect(prompt).toMatch(/PLAN DELTA since [0-9a-f]{12} \(\+1 -1 lines\):\n~~~~diff\n@@ /);
|
|
expect(prompt).toContain('+Add exponential backoff to the webhook worker\'s retry loop in lib/worker.ts: base 500 ms, factor 2, cap 60 s, dead-letter after 8 attempts.');
|
|
}
|
|
expect(readRunJson(s.out).phases.ceo.confirm).toMatchObject({ round: 2, added: 1, removed: 1 });
|
|
const planName = path.basename(readRunJson(s.out).plan_input);
|
|
const planLine = fs.readFileSync(readRunJson(s.out).plan_source, 'utf8').split('\n').findIndex(l => l.includes('dead-letter')) + 1;
|
|
r.review(path.join(s.out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([challenge]) });
|
|
r.review(path.join(s.out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ ...challenge, file: planName, line: planLine }]) });
|
|
const run = readRunJson(s.out);
|
|
const nat = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts.at(-1).result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]);
|
|
expect(nat.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native .* round=2 correlated=1 settled=1$/m);
|
|
const out = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts.at(-1).result, '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases.ceo.outside.attempt]);
|
|
expect(out.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=outside .* round=2 correlated=1 settled=0$/m);
|
|
const bound = readRunJson(s.out).phases.ceo;
|
|
const rows = [...readJsonl(path.join(s.out, `${bound.native.output}.findings.jsonl`)), ...readJsonl(path.join(s.out, `${bound.outside.output}.findings.jsonl`))];
|
|
expect(rows.map((x: any) => x.id)).toEqual([expect.stringMatching(/-ceo-r2-native-1$/), expect.stringMatching(/-ceo-r2-outside-1$/)]);
|
|
expect(rows[0].correlates).toEqual([expect.stringMatching(/-ceo-native-1$/), expect.stringMatching(/-ceo-outside-1$/)]);
|
|
expect(rows[0].settled_by).toBeTruthy();
|
|
expect(rows[1].settled_by).toBeUndefined();
|
|
});
|
|
});
|