mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user