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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-04 03:28:44 +00:00
1 parent 38b7a238a0
commit 92f22ea87e
5 files changed
+45 -8

No files matched your search

@@ -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.
+11
View File
@@ -180,6 +180,7 @@ const USAGE = [
'gstack-runaway — find runaway Capy threads and subagents (read-only; /runaway-stopper)',
' scan --thread <thread.get json> [--events <transcript json>] [--subagents <subagent.list json>] [--now <iso>]',
' scan --dir <dir> [--now <iso>] reads every <code>.thread.json with its <code>.events.json and <code>.subagents.json',
' and <dir>/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: "<Captain code>"';
} 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 {
+6 -4
View File
@@ -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++) {
+6 -4
View File
@@ -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++) {
+18
View File
@@ -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);