fix(ship): the pass program takes extras as JSON and a cwd, and waits a minute for a done reader's summary (replay C2-34)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 08:35:07 +00:00
1 parent 642db7f075
commit 9e6a4693f6
4 files changed
+38 -7

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections; Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and the operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship started. A small waiting child saves nothing.
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship started. A small waiting child saves nothing.
## Plan, approve, ship
+3
View File
@@ -4,3 +4,6 @@
- **`gstack-review-dispatch pass`** plans a pass and writes a staged manifest: audits marked first, then specialists with QA and the other audits, then the Red Team, then Step 11's voices. Each brief is rendered once its inputs exist, and the Red Team's findings file is the specialists' collected lines byte for byte.
- **Briefs travel in chunks** (`gstack-review-dispatch chunk`) of at most 65,536 characters, so a brief over 1 MB, or one whose diff has invalid UTF-8, reaches the reader whole and is checked by `sent` before the reader starts.
- **The Capy pass program** (`lib/capy-review-pass.js`, served by `gstack-review-dispatch program`) runs plan, review-log start, dispatch, poll, collect, the Red Team and the budget note in one call, returning within 200 s; the gstack Capy skill carries the loader. Each reader's intent is saved before its subagent is created, so a rerun after a lost create result finds the reader by title instead of starting it twice, and an unclear outcome stops the pass. A failed reader stops every rerun instead of leaving it polling, and a review-log start that failed is retried before any reader goes out.
### Fixed
- **The pass program takes its input as the Capy skill writes it.** `extras` may be JSON in the input, not only a file path, and `cwd` names the checkout its commands run in. A reader that reports done a moment before its summary arrives is collected on a later poll instead of stopping the pass. Replay C2-34 (GSTA-193) lost 7 of its 15 calls to these three issues.
+13 -3
View File
@@ -9,11 +9,13 @@ const T0 = input.startedAt ?? (await now());
const deadline = T0 + (input.budgetMs ?? 200000);
const tick = input.tickMs ?? 20000;
const stuckMs = input.stuckMs ?? 20 * 60000;
const emptyGraceMs = 60000;
const emptyDone = {};
const RETURN_BYTES = 32000;
const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
const lines = [];
const sh = async (cmd) => {
const r = await tools.capy.bash({ command: (input.launch ? '. ' + q(input.launch) + ' && ' : '') + cmd, description: 'Ship review pass ' + input.pass, timeout: 300 });
const r = await tools.capy.bash({ command: (input.launch ? '. ' + q(input.launch) + ' && ' : '') + (input.cwd ? 'cd ' + q(input.cwd) + ' && ' : '') + cmd, description: 'Ship review pass ' + input.pass, timeout: 300 });
return { code: r.exit_code, out: r.output || '' };
};
const bin = 'gstack-review-dispatch';
@@ -27,7 +29,11 @@ const stop = (why) => finish(['STOP: ' + why]);
const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)];
if (input.ledger) passCmd.push('--ledger', q(input.ledger));
if (input.extras) passCmd.push('--extras', q(input.extras));
if (input.extras && typeof input.extras !== 'string') {
const file = input.out.replace(/\/+$/, '') + '/extras.json';
await tools.capy.write({ path: file, content: JSON.stringify(Array.isArray(input.extras) ? input.extras : [input.extras]) });
passCmd.push('--extras', q(file));
} else if (input.extras) passCmd.push('--extras', q(input.extras));
if (input.learnings) passCmd.push('--learnings-file', q(input.learnings));
passCmd.push('--', ...(input.planFlags || []).map(q));
const pr = await sh(passCmd.join(' '));
@@ -113,7 +119,11 @@ try {
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') return stop(r.reader + ' (subagent ' + r.path + ') finished with no output');
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');
continue;
}
if (await stuck()) return stop(r.reader + ' (subagent ' + r.path + ') still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output');
continue;
}
+21 -3
View File
@@ -126,18 +126,20 @@ function repo(): { dir: string; env: NodeJS.ProcessEnv } {
return { dir, env };
}
interface Sub { title: string; prompt: string; status: string; polls: number; model?: unknown; summary?: string }
interface Sub { title: string; prompt: string; status: string; polls: number; model?: unknown; summary?: string; late?: boolean }
/** A fake Capy host: the shell is real (the repository's bins), subagents are scripted. */
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number } = {}) {
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean } = {}) {
let clock = 0;
const subs = new Map<string, Sub>();
const creates: Sub[] = [];
const starts: number[][] = [];
const advance = () => {
for (const s of subs.values()) {
if (s.late && !s.summary) { s.summary = o.output?.(s.title) ?? 'NO FINDINGS'; continue; }
if (s.status !== 'working') continue;
if (++s.polls < (o.pollsToFinish ?? 1)) continue;
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 = o.output?.(s.title) ?? 'NO FINDINGS'; }
}
};
@@ -371,6 +373,22 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(m.readers.some(r => r.effort === 'medium')).toBe(true);
}, 180_000);
// Value: protects=the loader's input is filled as SKILL.md reads it: extras as JSON, the checkout named by cwd, and a reader whose summary lands a tick after its done status is collected, not stopped; fails_when=extras must be a file path (replay C2-34: BRIEF_RENDER_FAILED [object Object]), the bins run in the jam's directory instead of the checkout (C2-34: a wrapper launch file), or a done reader without its summary yet stops the pass (C2-34: "finished with no output", one extra call);
// why_new=replay C2-34 (GSTA-193) on PR 2; seam=none; no_claim=the host's status timing beyond one late summary
test('extras as JSON, the checkout as cwd, and a late summary all run through without a STOP', async () => {
const { dir, env } = repo();
const elsewhere = tmp('rp-cwd-');
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief');
const h = host(elsewhere, env, { lateSummary: true });
const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'],
extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
expect(res).not.toContain('STOP:');
expect(res).toContain('NEXT: judge (Phase 4)');
expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe('QA brief');
}, 180_000);
test('a plan with no reviewer pass dispatches nothing and passes NEXT through', async () => {
const { dir, env } = repo();
const h = host(dir, env);
@@ -394,7 +412,7 @@ describe('the Capy skill loader', () => {
fs.writeFileSync(launch, '');
expect(spawnSync('bash', ['-c', 'gstack-ship-budget start --record "$1" --mode observe --reading 1000 --credits 1', '_', rec], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0);
const filled = code.split('<launch file>').join(launch).split('<base>').join('main').split('<run dir>/p<n>').join(path.join(dir, '.state/run/p1'))
.split('<run>-p<n>').join('x10p1').split('<jam id>').join('jam_test').split('<rec>').join(rec).split('<your model id>').join('anthropic/claude-opus-5-5');
.split('<run>-p<n>').join('x10p1').split('<jam id>').join('jam_test').split('<rec>').join(rec).split('<your model id>').join('anthropic/claude-opus-5-5').split('<repo>').join(dir);
expect(filled).not.toMatch(/<[a-z ]+>/);
const h = host(dir, env);
const res = await new AsyncFunction('tools', 'sleep', 'now', filled)(h.tools, h.sleep, h.now);