From 275df49b1a8fd2866c13299b71c4271885bc61fb Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 00:27:53 +0000 Subject: [PATCH] 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> --- .../gsta214-gst1104-host-voice-stop.md | 2 +- docs/unattended.md | 8 ++++--- lib/capy-review-pass.js | 9 ++++++-- test/review-pass.test.ts | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/changelog.d/gsta214-gst1104-host-voice-stop.md b/changelog.d/gsta214-gst1104-host-voice-stop.md index fc22aa77a..761ece50b 100644 --- a/changelog.d/gsta214-gst1104-host-voice-stop.md +++ b/changelog.d/gsta214-gst1104-host-voice-stop.md @@ -1,6 +1,6 @@ **/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, stops, fails or finishes empty is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`), 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`**: 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 a review is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`); one that stops or fails after writing its review 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. diff --git a/docs/unattended.md b/docs/unattended.md index 788c9ffef..3e045efb6 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -240,9 +240,11 @@ 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. An outside voice still running with no output after `input.stuckMs` (20 minutes by -default) is stopped, and one that stops, fails 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. That outside voice then gets its own +default) is stopped, and one that stops or fails without leaving a review, 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 that did leave a review is collected like any +finished one, so its findings are kept. 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. diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index ac86d72c6..646d39d9c 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -5,8 +5,8 @@ // 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. The outside -// voice is the exception (GST-1104): one that hangs past stuckMs is stopped, and one that hangs, stops, fails or -// ends empty is collected as unavailable coverage; Step 11 returns OUTSIDE_RECORD: for its review-log rows. +// voice is the exception (GST-1104): one that hangs past stuckMs is stopped, and one that hangs, ends empty, or +// stops or fails without a review is collected as unavailable coverage (one that left a review is collected); Step 11 returns OUTSIDE_RECORD: for its review-log rows. // Once Phase 2 has started /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. @@ -228,6 +228,11 @@ try { await tools.capy.write({ path: file, content: g.summary }); 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 && ended && c.out.includes('COLLECT_NOT_REVIEWED')) { + const s = await recordUnavailable(m, r, t.status + ' with no review'); + if (s) return s; + continue; + } if (c.code !== 0) return stop((r.prompt_file ? 'mark ' : 'collect ') + r.reader + ' exited ' + c.code + ': ' + c.out.slice(-500)); if (!r.prompt_file) { lines.push(c.out.trim()); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 6792cf7c7..bf52d1080 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -553,6 +553,27 @@ describe('Capy pass program (fake host, real bins)', () => { } }, 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); + }, 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');