mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.93.39.0 fix(ship): stop a hung outside voice and record it unavailable as the host subagent (GST-1104) (#3895)
* fix(ship): stop a hung outside voice and record it unavailable as the host subagent (GST-1104) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-pass): collect a stopped or failed outside voice's report before marking it unavailable (GST-1104) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-pass): an ended outside voice with a non-review summary is unavailable, not a stuck pass (GST-1104) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-pass): write an unavailable outside voice's failure text to its own file so verify keeps the earlier output (GST-1104) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-pass): an ended outside voice with no findings is collected unavailable from its own reply, so verify keeps no NOT_REVIEWED row (GST-1104) 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>
This commit is contained in:
1 parent
a65404886b
commit
d4bef0a6a3
7 files changed
+164
-16
No files matched your search
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.39.0] - 2026-11-03
|
||||
|
||||
**/ship on Capy stops an outside voice that hangs and records it as missing, never as converged or as an unavailable Codex CLI (GST-1104).** TRUT-7's /ship run had two outside voices end with no result (gpt-6.1-sol stopped after 20 minutes; grok-4.7 used 16.9M tokens before it was stopped), and its Step 11 row read `outside_provider: codex`, `outside_status: unavailable`, `converged: true`.
|
||||
|
||||
### Fixed
|
||||
- **`lib/capy-review-pass.js`**: an outside voice still running with no output after `input.stuckMs` (20 minutes by default) is stopped. One that hangs, finishes empty, or stops or fails without leaving findings is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`); one that stops or fails after writing findings is collected normally, so its findings are kept, and Step 11 goes on to judge the native voice instead of stopping the pass with nothing recorded. Other readers still stop the pass as before.
|
||||
- **`lib/capy-review-pass.js`**: Step 11 returns an `OUTSIDE_RECORD:` line with `outside_provider: host-subagent`, the model that ran and its status, for the review-log rows to copy instead of the template's `codex`. When the voice was unavailable, the line tells the parent to write that voice its own row first (`status: unavailable`, `completed` and `converged` false), so the native row's convergence no longer stands in for it.
|
||||
- **`docs/unattended.md`**: "The outside voice on Capy" describes the stop, the unavailable record and the new fields.
|
||||
|
||||
## [1.93.38.0] - 2026-11-03
|
||||
|
||||
- **Review collector and blocker count follow what the reader said (GST-1031).** A native adversarial finding that states `Blocks: no` and one Severity now collects once, at that severity, on every review class; a numbered finding heading keeps its body (located or numbered bullets, a bold subheading, a located paragraph) so its Severity line no longer splits off as a second CRITICAL record. A closing summary such as "so nothing is CRITICAL" is marked `summary_candidate`, and lead judgment may lower it with a reason that `judge` records as `dismissed`. A CRITICAL whose reader writes the field `Blocks: no` no longer counts as a blocker in `REVIEW_LOOP`'s ledger count (classify does the same for a finding that carries `stated`; Step 9.4 does not copy it yet). Decision 3.5's fix-check pass takes a `review-fix-check` start token even when given `logStart: ["review"]`, so it is never counted as a pass. `gstack-ship-release finish` routes a `scripts/harness-version.json` edit that only records non-behavioral pins at the same version as release metadata.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.38.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.39.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+13
-3
@@ -216,8 +216,9 @@ review-log row is the same row every Step 11 pass writes
|
||||
(the outside provider that reviewed, with the model recorded, for the
|
||||
cross-family subagent; `codex` for completed Codex CLI output, as
|
||||
`ship/sections/adversarial.md` prescribes),
|
||||
`outside_provider` (the provider the host would have used, as the generated
|
||||
prose names it), `outside_status` (`completed` for a finished outside pass;
|
||||
`outside_provider` (on Capy, `host-subagent`, with `outside_model`, both copied
|
||||
from the pass program's `OUTSIDE_RECORD:` line; elsewhere the provider the
|
||||
generated prose names), `outside_status` (`completed` for a finished outside pass;
|
||||
`unavailable` when neither the CLI nor a subagent completed, the cause on the
|
||||
`gstack-outside-voice run:` stderr line and in `<out>.meta.json` rather than
|
||||
in the row; `disabled` under
|
||||
@@ -237,7 +238,16 @@ when `input.models['outside-voice']` is empty and the lane model is
|
||||
rounds ran. Any other lane must name a model. An outside voice from the same
|
||||
provider as the lane model or the native voice stops the pass (`STOP:`) before
|
||||
either voice is created, including on a rerun, and each outside voice prints an
|
||||
`OUTSIDE_VOICE:` line saying whether it is the certified configuration.
|
||||
`OUTSIDE_VOICE:` line saying whether it is the certified configuration. An
|
||||
outside voice still running with no output after `input.stuckMs` (20 minutes by
|
||||
default) is stopped, and one that stops or fails without leaving findings, or
|
||||
finishes empty, is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`)
|
||||
instead of stopping the pass, so Step 11 goes on to judge the native voice. A
|
||||
stopped or failed outside voice whose reply holds findings is collected like any
|
||||
finished one, so its findings are kept; one whose reply holds none is unavailable. That outside voice then gets its own
|
||||
row (`source: "host-subagent"`, `status: "unavailable"`, `completed` and
|
||||
`converged` false, written without `--finish` before the native row), so a
|
||||
missing voice never reads as converged or as an unavailable Codex CLI.
|
||||
|
||||
### The carry across a base merge or a test-only fix
|
||||
|
||||
|
||||
+46
-5
@@ -4,7 +4,9 @@
|
||||
// fix-check with its plan's voices included, whose `review` start becomes `review-fix-check`, never a pass: GST-1031) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`,
|
||||
// intent before create, create, poll, collect. It returns 200 s after the loader's first line
|
||||
// (input.startedAt) at most; the next call resumes from the manifest. A failed, stuck or ambiguous
|
||||
// reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped.
|
||||
// reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped. The outside
|
||||
// voice is the exception (GST-1104): one that hangs past stuckMs is stopped, and one that hangs, ends empty, or
|
||||
// stops or fails without findings is collected as unavailable coverage (one that left findings is collected); Step 11 returns OUTSIDE_RECORD: for its review-log rows.
|
||||
// Once Phase 2 has started <run dir>/parts (the parent of input.out), each poll or finished pass also
|
||||
// returns the next phase's section parts that fit its 32 KB (`gstack-ship-parts next`, GSTA-167 C5);
|
||||
// after a return that never reached the parent, rerun with input.resendParts to get that batch again.
|
||||
@@ -124,6 +126,16 @@ const familyStop = (readers) => {
|
||||
return null;
|
||||
};
|
||||
const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const UNAVAILABLE = 'unavailable: ';
|
||||
const recordUnavailable = async (m, r, why) => {
|
||||
const file = m.out + '/out/' + safe(r.reader) + '.unavailable.txt';
|
||||
await tools.capy.write({ path: file, content: 'outside voice (' + modelOf(r).model_id + ') ' + why + '\n' });
|
||||
const c = await sh(bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf) + ' --unavailable ' + q(why));
|
||||
if (c.code !== 0) return stop('collect --unavailable ' + r.reader + ' exited ' + c.code + ': ' + c.out.slice(-500));
|
||||
lines.push(c.out.trim());
|
||||
await mark(r.reader, 'collected', { output: file, error: UNAVAILABLE + why });
|
||||
return null;
|
||||
};
|
||||
const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team'];
|
||||
const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before';
|
||||
|
||||
@@ -186,20 +198,44 @@ try {
|
||||
for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'created')) {
|
||||
const t = byPath.get(r.path);
|
||||
if (!t) return stop(r.reader + "'s subagent " + r.path + ' is missing from the subagent list');
|
||||
if (t.status === 'failed' || t.status === 'stopped') { await mark(r.reader, 'failed', { error: t.status }); return stop(r.reader + ' (subagent ' + r.path + ') ' + t.status + '; its review is incomplete, so the pass cannot certify coverage'); }
|
||||
const outside = r.role === 'outside-voice';
|
||||
const ended = t.status === 'failed' || t.status === 'stopped';
|
||||
if (ended && !outside) { await mark(r.reader, 'failed', { error: t.status }); return stop(r.reader + ' (subagent ' + r.path + ') ' + t.status + '; its review is incomplete, so the pass cannot certify coverage'); }
|
||||
const stuck = async () => r.state_at && (await now()) - Date.parse(r.state_at) > stuckMs;
|
||||
const g = t.status === 'done' || t.status === 'idle' ? await tools.subagent.get({ path: r.path, transcript: 'none' }) : null;
|
||||
const g = t.status === 'done' || t.status === 'idle' || ended ? await tools.subagent.get({ path: r.path, transcript: 'none' }) : null;
|
||||
if (ended && !(g && g.summary)) {
|
||||
const s = await recordUnavailable(m, r, t.status + ' with no result');
|
||||
if (s) return s;
|
||||
continue;
|
||||
}
|
||||
if (!g || !g.summary) {
|
||||
if (t.status === 'done') {
|
||||
const seen = (emptyDone[r.reader] ??= await now());
|
||||
if ((await now()) - seen >= emptyGraceMs) return stop(r.reader + ' (subagent ' + r.path + ') finished with no output');
|
||||
if ((await now()) - seen < emptyGraceMs) continue;
|
||||
if (!outside) return stop(r.reader + ' (subagent ' + r.path + ') finished with no output');
|
||||
const s = await recordUnavailable(m, r, 'finished with no output');
|
||||
if (s) return s;
|
||||
continue;
|
||||
}
|
||||
if (await stuck()) return stop(r.reader + ' (subagent ' + r.path + ') still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output');
|
||||
if (!(await stuck())) continue;
|
||||
const why = 'still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output';
|
||||
if (!outside) return stop(r.reader + ' (subagent ' + r.path + ') ' + why);
|
||||
await tools.subagent.stop({ paths: [r.path] });
|
||||
const s = await recordUnavailable(m, r, why + '; stopped');
|
||||
if (s) return s;
|
||||
continue;
|
||||
}
|
||||
const file = m.out + '/out/' + safe(r.reader) + '.txt';
|
||||
await tools.capy.write({ path: file, content: g.summary });
|
||||
if (ended) {
|
||||
const why = t.status + ' before finishing its review';
|
||||
const u = await sh(bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf) + ' --unavailable ' + q(why));
|
||||
if (u.code === 0) {
|
||||
lines.push(u.out.trim());
|
||||
await mark(r.reader, 'collected', { output: file, error: UNAVAILABLE + why });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const c = await sh(r.prompt_file ? bin + ' mark --manifest ' + q(mf) + ' --reader ' + q(r.reader) + ' --state collected --output ' + q(file)
|
||||
: bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf));
|
||||
if (c.code !== 0) return stop((r.prompt_file ? 'mark ' : 'collect ') + r.reader + ' exited ' + c.code + ': ' + c.out.slice(-500));
|
||||
@@ -266,6 +302,11 @@ try {
|
||||
const o = await sh('cat ' + q(r.output));
|
||||
outs.push('OUTPUT: ' + r.reader + ' file=' + r.output, o.out.trimEnd());
|
||||
}
|
||||
for (const r of done.filter((x) => x.role === 'outside-voice')) {
|
||||
const lost = String(r.error || '').startsWith(UNAVAILABLE);
|
||||
outs.push('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":' + JSON.stringify(modelOf(r).model_id) + ',"outside_status":"' + (lost ? 'unavailable' : 'completed') + '" (Step 11\'s review-log rows carry these fields, never the template\'s codex'
|
||||
+ (lost ? '; first write this voice its own adversarial-review row with "source":"host-subagent","status":"unavailable","completed":false,"converged":false and no --finish, then the native row' : '') + ')');
|
||||
}
|
||||
if (noteExit !== 0) return finish([...outs, 'STOP: gstack-ship-budget note exited ' + noteExit + ' (its lines above say why); judge nothing further until the budget allows it']);
|
||||
return await withParts([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : input.step === '14.5' ? 'NEXT: apply the docs audit (Step 14.5)' : 'NEXT: judge (Phase 4)']);
|
||||
} catch (e) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.38",
|
||||
"version": "1.93.39",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -144,18 +144,20 @@ function treeLine(dir: string): string {
|
||||
return tree.status === 0 && top.status === 0 ? `REVIEWED_TREE: ${tree.stdout.trim()} ${top.stdout.trim()}\n` : '';
|
||||
}
|
||||
const briefDir = (prompt: string) => /Workspace: the checkout under review is (.+?)\. Your shell and file tools/.exec(prompt)?.[1] ?? '';
|
||||
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; reportFrom?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean } = {}) {
|
||||
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; reportFrom?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean; hang?: (title: string) => boolean; stopped?: (title: string) => boolean; keepSummary?: boolean; epoch?: number } = {}) {
|
||||
let clock = 0;
|
||||
const subs = new Map<string, Sub>();
|
||||
const creates: Sub[] = [];
|
||||
const starts: number[][] = [];
|
||||
const stops: string[] = [];
|
||||
const reply = (s: Sub) => treeLine(o.reportFrom?.(s.title) ?? briefDir(s.prompt)) + (o.output?.(s.title) ?? cleanReply(s.title));
|
||||
const advance = () => {
|
||||
for (const s of subs.values()) {
|
||||
if (s.late && !s.summary) { s.summary = reply(s); continue; }
|
||||
if (s.status !== 'working') continue;
|
||||
if (s.status !== 'working' || o.hang?.(s.title)) continue;
|
||||
if (++s.polls < (o.pollsToFinish ?? 1)) continue;
|
||||
if (o.fail?.(s.title)) s.status = 'failed';
|
||||
if (o.stopped?.(s.title)) { s.status = 'stopped'; if (o.keepSummary) s.summary = reply(s); }
|
||||
else if (o.fail?.(s.title)) { s.status = 'failed'; if (o.keepSummary) s.summary = reply(s); }
|
||||
else if (o.lateSummary && !s.late) { s.status = 'done'; s.late = true; }
|
||||
else { s.status = 'done'; s.summary = reply(s); }
|
||||
}
|
||||
@@ -185,10 +187,11 @@ function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string)
|
||||
start: async ({ indices, model }: { indices: number[]; model?: unknown }) => { starts.push(indices); for (const i of indices) { const s = subs.get(String(i))!; s.status = 'working'; if (model) s.model = model; } return { started: indices.map(index => ({ index })), rejected: [] }; },
|
||||
list: async () => { advance(); return { tasks: [...subs].map(([p, s]) => ({ path: p, title: s.title, status: s.status })), total: subs.size }; },
|
||||
get: async ({ path: p }: { path: string }) => ({ path: p, status: subs.get(p)!.status, summary: subs.get(p)!.summary }),
|
||||
stop: async ({ paths }: { paths: string[] }) => { stops.push(...paths); for (const p of paths) subs.get(p)!.status = 'stopped'; return { results: paths.map(p => ({ path: p, ok: true })) }; },
|
||||
},
|
||||
thread: { get: async () => ({ cost: { tokens: 1000 + clock, credits: 1 + clock / 1000 } }) },
|
||||
};
|
||||
return { tools, subs, creates, starts, sleep: async (ms: number) => { clock += ms; }, now: async () => 1_000_000 + clock };
|
||||
return { tools, subs, creates, starts, stops, sleep: async (ms: number) => { clock += ms; }, now: async () => (o.epoch ?? 1_000_000) + clock };
|
||||
}
|
||||
|
||||
const PROGRAM = fs.readFileSync(path.join(ROOT, 'lib/capy-review-pass.js'), 'utf8');
|
||||
@@ -362,7 +365,9 @@ describe('Capy pass program (fake host, real bins)', () => {
|
||||
expect(await runPass(h, { ...base, stage: 'voices', laneModel: undefined })).toContain("STOP: the outside voice needs input.models['outside-voice']");
|
||||
expect(h.creates.some(c => c.title.startsWith('ship x7p1 ') && /adversarial|outside/.test(c.title))).toBe(false);
|
||||
const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } };
|
||||
expect(await runPass(h, { ...base, stage: 'voices', models })).toContain('NEXT: judge the voices');
|
||||
const voices = await runPass(h, { ...base, stage: 'voices', models });
|
||||
expect(voices).toContain('NEXT: judge the voices');
|
||||
expect(voices).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"completed"');
|
||||
expect(h.creates.find(c => c.title === 'ship x7p1 outside-voice')!.model).toEqual(models['outside-voice']);
|
||||
expect(h.creates.find(c => c.title === 'ship x7p1 adversarial-native')!.model).toEqual(models['adversarial-native']);
|
||||
}, 240_000);
|
||||
@@ -489,6 +494,89 @@ describe('Capy pass program (fake host, real bins)', () => {
|
||||
for (const t of Object.keys(named.models)) if (t !== 'ship x17p1 outside-voice') expect(over.models[t]).toEqual(named.models[t]);
|
||||
}, 300_000);
|
||||
|
||||
// Value: protects=GST-1104 (TRUT-7, truther#1159): an outside voice that runs past the stuck limit, or ends stopped or failed with no result, is stopped and recorded as unavailable coverage under its own runner and model, and Step 11 goes on to judge the native voice; fails_when=the stuck subagent is left running (TRUT-7 p2 burned 16.9M tokens before anyone stopped it), the pass stops with nothing recorded, or the returned record names no host-subagent runner so the review-log row falls back to the template's codex;
|
||||
// why_new=GST-1104; seam=lib/capy-review-pass.js reader poll; no_claim=which outside model Step 11 picks
|
||||
test('an outside voice that hangs or stops with no result is stopped and recorded unavailable as the host subagent, and Step 11 goes on (GST-1104)', async () => {
|
||||
for (const [pass, o] of [['x30p1', { hang: (t: string) => t.endsWith('outside-voice') }], ['x31p1', { stopped: (t: string) => t.endsWith('outside-voice') }], ['x32p1', { fail: (t: string) => t.endsWith('outside-voice') }]] as const) {
|
||||
const { dir, env } = repo();
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
const h = host(dir, env, { ...o, epoch: Date.now() });
|
||||
const base = { base: 'main', out, pass, planFlags: ['--depth-floor', 'full'] };
|
||||
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
|
||||
let res = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
|
||||
if (!res.includes('NEXT: poll')) break;
|
||||
}
|
||||
expect(res).not.toContain('STOP:');
|
||||
expect(res).toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
|
||||
expect(res).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"unavailable"');
|
||||
expect(res).toContain('first write this voice its own adversarial-review row with "source":"host-subagent","status":"unavailable","completed":false,"converged":false and no --finish, then the native row');
|
||||
expect(res).toContain('NEXT: judge the voices');
|
||||
if ('hang' in o) {
|
||||
const token = /REVIEW_LOG_START: adversarial-review token=(\S+)/.exec(res)![1]!;
|
||||
const record = /OUTSIDE_RECORD: (\S+)/.exec(res)![1]!;
|
||||
const log = (row: string, a: string[] = []) => spawnSync(path.join(BIN, 'gstack-review-log'), [row, ...a], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(log('{"skill":"adversarial-review","status":"unavailable","source":"host-subagent",' + record + ',"phase":"adversarial","completed":false,"converged":false}').status).toBe(0);
|
||||
expect(log('{"skill":"adversarial-review","status":"clean","source":"in-host",' + record + ',"phase":"adversarial","completed":true,"converged":true}', ['--finish', token]).status).toBe(0);
|
||||
const rows = spawnSync(path.join(BIN, 'gstack-review-read'), [], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).stdout.split('---CONFIG---')[0]!.trim().split('\n').map(l => JSON.parse(l));
|
||||
expect(rows.map(r => [r.source, r.outside_provider, r.outside_status, r.converged])).toEqual([['host-subagent', 'host-subagent', 'unavailable', false], ['in-host', 'host-subagent', 'unavailable', true]]);
|
||||
expect(rows[1].review_freshness).toMatchObject({ status: 'CURRENT' });
|
||||
}
|
||||
const ov = [...h.subs].find(([, s]) => s.title === 'ship ' + pass + ' outside-voice')!;
|
||||
expect(ov[1].status).not.toBe('working');
|
||||
expect(h.stops).toEqual('hang' in o ? [ov[0]] : []);
|
||||
const v = bin(dir, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]);
|
||||
expect(v.stdout).toMatch(/^BRIEF outside-voice UNAVAILABLE/m);
|
||||
expect(v.stdout).not.toContain('MISMATCH');
|
||||
expect(v.stdout).toContain('unavailable=1');
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
// Value: protects=GST-1104 review finding: an outside voice that wrote its report and then ended stopped or failed is collected from its summary, never replaced by unavailable coverage; fails_when=the pass records the voice unavailable without reading the summary, silently dropping its findings;
|
||||
// why_new=GST-1104 /ship review (three readers); seam=lib/capy-review-pass.js reader poll; no_claim=what the voice found
|
||||
test('an outside voice that ends stopped or failed with a report is collected, not marked unavailable (GST-1104)', async () => {
|
||||
for (const [pass, o] of [['x33p1', { stopped: (t: string) => t.endsWith('outside-voice') }], ['x34p1', { fail: (t: string) => t.endsWith('outside-voice') }]] as const) {
|
||||
const { dir, env } = repo();
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
const h = host(dir, env, { ...o, keepSummary: true, output: (t: string) => (t.endsWith('outside-voice') ? '1. **A guard fails open** - `src/a.ts:1` accepts any input. Severity: Medium. Blocks: no.\n\nRecommendation: fix the guard because it fails open' : cleanReply(t)), epoch: Date.now() });
|
||||
const base = { base: 'main', out, pass, planFlags: ['--depth-floor', 'full'] };
|
||||
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
|
||||
let res = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
|
||||
if (!res.includes('NEXT: poll')) break;
|
||||
}
|
||||
expect(res).not.toContain('STOP:');
|
||||
expect(res).not.toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
|
||||
expect(res).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"completed"');
|
||||
expect(res).toContain('NEXT: judge the voices');
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
// Value: protects=GST-1104 review finding: an outside voice that ends stopped or failed with a summary that is not a review is recorded unavailable and Step 11 goes on; fails_when=collect's COLLECT_NOT_REVIEWED stops the pass and every rerun stops the same way;
|
||||
// why_new=GST-1104 /ship review round 2; seam=lib/capy-review-pass.js reader collect; no_claim=what the summary says
|
||||
test('an outside voice that ends stopped with a non-review summary is recorded unavailable, not a stuck pass (GST-1104)', async () => {
|
||||
const { dir, env } = repo();
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
const h = host(dir, env, { stopped: (t: string) => t.endsWith('outside-voice'), keepSummary: true, output: (t: string) => (t.endsWith('outside-voice') ? 'Stopped before I could read the diff.' : cleanReply(t)), epoch: Date.now() });
|
||||
const base = { base: 'main', out, pass: 'x35p1', planFlags: ['--depth-floor', 'full'] };
|
||||
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
|
||||
let res = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
|
||||
if (!res.includes('NEXT: poll')) break;
|
||||
}
|
||||
expect(res).not.toContain('STOP:');
|
||||
expect(res).toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
|
||||
expect(res).toContain('"outside_status":"unavailable"');
|
||||
expect(res).toContain('NEXT: judge the voices');
|
||||
const v = bin(dir, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]);
|
||||
expect(v.stdout).toMatch(/^BRIEF outside-voice UNAVAILABLE/m);
|
||||
expect(v.stdout).not.toContain('MISMATCH');
|
||||
expect(v.stdout).not.toContain('NOT_REVIEWED');
|
||||
}, 300_000);
|
||||
|
||||
test('an audit reader marked first settles before any specialist starts; its output is returned, never merged', async () => {
|
||||
const { dir, env } = repo();
|
||||
const out = path.join(dir, '.state/run/p1');
|
||||
|
||||
Reference in new issue
Block a user