diff --git a/changelog.d/gsta214-gst1104-host-voice-stop.md b/changelog.d/gsta214-gst1104-host-voice-stop.md new file mode 100644 index 000000000..fc22aa77a --- /dev/null +++ b/changelog.d/gsta214-gst1104-host-voice-stop.md @@ -0,0 +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`**: 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 5fe0b514d..788c9ffef 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -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 `.meta.json` rather than in the row; `disabled` under @@ -237,7 +238,14 @@ 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, 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 +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 diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 886976f34..2cc6805b0 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -4,7 +4,9 @@ // fix-check with its plan's voices included) 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, stops, fails or +// ends empty is collected as unavailable coverage; 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. @@ -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) + '.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'; @@ -185,16 +197,30 @@ 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'); + const outside = r.role === 'outside-voice'; + if (outside && (t.status === 'failed' || t.status === 'stopped')) { + const s = await recordUnavailable(m, r, t.status + ' with no result'); + if (s) return s; + continue; + } 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 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; 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'; @@ -265,6 +291,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) { diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 2e8ec1126..7884442a6 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -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; epoch?: number } = {}) { let clock = 0; const subs = new Map(); 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'; + else if (o.fail?.(s.title)) s.status = 'failed'; 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,44 @@ 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).toContain('unavailable=1'); + } + }, 600_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');