From 92f22ea87e522f92afecd9187eaf33d84d12ef19 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 19:28:44 -0800 Subject: [PATCH] fix(runaway-stopper): scan the crew by captain code from an automation thread; an empty crew list is RUNAWAY_INPUT (#3983) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta-266-runaway-crew-scan.md | 4 ++++ lib/runaway.ts | 11 +++++++++++ runaway-stopper/SKILL.md | 10 ++++++---- runaway-stopper/SKILL.md.tmpl | 10 ++++++---- test/runaway.test.ts | 18 ++++++++++++++++++ 5 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 changelog.d/gsta-266-runaway-crew-scan.md diff --git a/changelog.d/gsta-266-runaway-crew-scan.md b/changelog.d/gsta-266-runaway-crew-scan.md new file mode 100644 index 000000000..c2fefe870 --- /dev/null +++ b/changelog.d/gsta-266-runaway-crew-scan.md @@ -0,0 +1,4 @@ +bump: micro +--- +### Fixed +- **`/runaway-stopper` no longer reports a clean sweep when it was run from outside the crew.** The skill tells big crews to run its scan from a fresh automation thread. That thread belongs to no crew, so its crew list came back empty and the scan checked only itself, then printed a clean `SWEEP:`. The program now takes the Captain's code (`captain`) and writes the crew list to `crew.json`. `gstack-runaway scan --dir` treats an empty list as `RUNAWAY_INPUT` and exits 1. diff --git a/lib/runaway.ts b/lib/runaway.ts index ea9c8b8cf..116397009 100644 --- a/lib/runaway.ts +++ b/lib/runaway.ts @@ -180,6 +180,7 @@ const USAGE = [ 'gstack-runaway — find runaway Capy threads and subagents (read-only; /runaway-stopper)', ' scan --thread [--events ] [--subagents ] [--now ]', ' scan --dir [--now ] reads every .thread.json with its .events.json and .subagents.json', + ' and /crew.json (the thread.list codes, {"items": [...]}): an empty crew list is RUNAWAY_INPUT, never a clean SWEEP.', 'Lines: RUNAWAY (one per thread, largest own tokens first), FLAG ROTATE|STOP|LOOK|WARN|REPORT, DEFER, SWEEP (--dir).', 'Review rounds count only subagent titles that name the PR (#N); the rest show as unattributed.', 'Limits (R70): own > 500M or active > 3d rotate (warn 400M); subagent > 100M stop (LOOK when it already ended);', @@ -217,6 +218,16 @@ export function runawayMain(argv: string[]): number { for (const s of sets) for (const f of [s.thread, s.events, s.subagents]) if (f && !fs.existsSync(f)) return fail('no file ' + f); const verdicts: Verdict[] = []; let unreadable = 0; + const crewFile = dir ? path.join(dir, 'crew.json') : undefined; + if (crewFile && fs.existsSync(crewFile)) { + let problem: string | null = null; + try { + const items = (readJson(crewFile) as { items?: unknown })?.items; + if (!Array.isArray(items)) problem = 'field=items expected=array of thread codes'; + else if (!items.length) problem = 'the crew list is empty; a thread outside every crew lists none, so pass thread.list captain: ""'; + } catch (e) { problem = (e instanceof Error ? e.message : String(e)).split('\n')[0]!; } + if (problem) { unreadable++; console.log('RUNAWAY_INPUT: file=' + crewFile + ' ' + problem); } + } for (const s of sets) { let file = s.thread; try { diff --git a/runaway-stopper/SKILL.md b/runaway-stopper/SKILL.md index 9da0a49b3..fe137aedd 100644 --- a/runaway-stopper/SKILL.md +++ b/runaway-stopper/SKILL.md @@ -150,8 +150,8 @@ Needs Capy's thread and subagent tools; without them, say so and stop (`gstack-r files). Step 1 only reads. Stop only your own subagents; only a Captain rotates and archives, and only threads in its own crew (R39). An org sweep runs Step 1 everywhere and sends each finding to the owning Captain; threads outside every crew are left alone. For a crew over ~15 threads or the org sweep, run Step 1 from an automation -with a fresh thread per run that writes the lines to the Captain's drive, archives itself, and messages the -Captain only when a ROTATE, STOP or REPORT line appears. +with a fresh thread per run that sets `captain` below (it is in no crew), writes the lines to the Captain's +drive, archives itself, and messages the Captain on a ROTATE, STOP, REPORT or RUNAWAY_INPUT line. ## 1. Detect @@ -162,8 +162,10 @@ const pages = 3; // 1 for an org sweep; empty_wake needs 20+ turns, else it prin const max = Math.floor(240 / (pages + 1)); // threads per program, under the 256-call limit const cursor = undefined; // the previous program's NEXT_CURSOR, to continue a large sweep const dir = "/home/user/.capy/work/runaway/" + crypto.randomUUID(); -const list = await tools.thread.list({ scope: "crew", status: ["working", "waiting", "idle"], limit: Math.min(100, max), cursor }); // org sweep: scope "all" -const codes = [...list.items.map(t => t.code), ...(cursor ? [] : ["this"])].slice(0, max); +const captain = undefined; // from an automation thread: the Captain's code +const list = await tools.thread.list({ ...(captain ? { captain } : { scope: "crew" }), status: ["working", "waiting", "idle"], limit: Math.min(100, max), cursor }); // org sweep: scope "all" +await tools.capy.write({ path: `${dir}/crew.json`, content: JSON.stringify({ items: list.items.map(t => t.code) }) }); +const codes = [...list.items.map(t => t.code), ...(cursor ? [] : [captain ?? "this"])].slice(0, max); const fetch = async code => { let before, events = [], doc; for (let k = 0; k < pages; k++) { diff --git a/runaway-stopper/SKILL.md.tmpl b/runaway-stopper/SKILL.md.tmpl index 91c1dafa0..64ade4525 100644 --- a/runaway-stopper/SKILL.md.tmpl +++ b/runaway-stopper/SKILL.md.tmpl @@ -26,8 +26,8 @@ Needs Capy's thread and subagent tools; without them, say so and stop (`gstack-r files). Step 1 only reads. Stop only your own subagents; only a Captain rotates and archives, and only threads in its own crew (R39). An org sweep runs Step 1 everywhere and sends each finding to the owning Captain; threads outside every crew are left alone. For a crew over ~15 threads or the org sweep, run Step 1 from an automation -with a fresh thread per run that writes the lines to the Captain's drive, archives itself, and messages the -Captain only when a ROTATE, STOP or REPORT line appears. +with a fresh thread per run that sets `captain` below (it is in no crew), writes the lines to the Captain's +drive, archives itself, and messages the Captain on a ROTATE, STOP, REPORT or RUNAWAY_INPUT line. ## 1. Detect @@ -38,8 +38,10 @@ const pages = 3; // 1 for an org sweep; empty_wake needs 20+ turns, else it prin const max = Math.floor(240 / (pages + 1)); // threads per program, under the 256-call limit const cursor = undefined; // the previous program's NEXT_CURSOR, to continue a large sweep const dir = "/home/user/.capy/work/runaway/" + crypto.randomUUID(); -const list = await tools.thread.list({ scope: "crew", status: ["working", "waiting", "idle"], limit: Math.min(100, max), cursor }); // org sweep: scope "all" -const codes = [...list.items.map(t => t.code), ...(cursor ? [] : ["this"])].slice(0, max); +const captain = undefined; // from an automation thread: the Captain's code +const list = await tools.thread.list({ ...(captain ? { captain } : { scope: "crew" }), status: ["working", "waiting", "idle"], limit: Math.min(100, max), cursor }); // org sweep: scope "all" +await tools.capy.write({ path: `${dir}/crew.json`, content: JSON.stringify({ items: list.items.map(t => t.code) }) }); +const codes = [...list.items.map(t => t.code), ...(cursor ? [] : [captain ?? "this"])].slice(0, max); const fetch = async code => { let before, events = [], doc; for (let k = 0; k < pages; k++) { diff --git a/test/runaway.test.ts b/test/runaway.test.ts index cfd20ec46..fa774dc78 100644 --- a/test/runaway.test.ts +++ b/test/runaway.test.ts @@ -138,6 +138,24 @@ describe('gstack-runaway scan', () => { expect(r.code).toBe(0); }); + // Value: protects=a sweep from a thread outside every crew (the fresh automation thread the skill recommends) never reports a clean SWEEP over its own thread alone; fails_when=an empty crew list scans only "this" and exits 0; why_new=#3975 open note 3 (red-team, cross-family trail read) + test('an empty crew list in crew.json is RUNAWAY_INPUT and fails the sweep; a listed crew scans clean', () => { + const d = path.join(tmp, 'empty-crew'); + fs.mkdirSync(d); + fs.writeFileSync(path.join(d, 'AUTO-1.thread.json'), JSON.stringify({ code: 'AUTO-1', status: 'working', created_at: '2026-11-03T21:00:00Z', cost: { tokens: 1e6 }, tasks: [] })); + fs.writeFileSync(path.join(d, 'crew.json'), JSON.stringify({ items: [] })); + const r = run('scan', '--dir', d, '--now', NOW); + expect(r.code).toBe(1); + expect(r.out).toMatch(/RUNAWAY_INPUT: file=\S*crew\.json the crew list is empty/); + expect(r.out).toContain('unreadable=1'); + fs.writeFileSync(path.join(d, 'crew.json'), JSON.stringify({ items: ['AUTO-1'] })); + const ok = run('scan', '--dir', d, '--now', NOW); + expect(ok.code).toBe(0); + expect(ok.out).toContain('ok=1 unreadable=0'); + fs.writeFileSync(path.join(d, 'crew.json'), JSON.stringify({ codes: [] })); + expect(run('scan', '--dir', d, '--now', NOW).out).toMatch(/crew\.json field=items expected=array/); + }); + test('an unreadable or incomplete thread file is named, skipped and fails the sweep; the rest still scan', () => { const d = path.join(tmp, 'bad'); fs.mkdirSync(d);