diff --git a/changelog.d/runaway-scan-never-rotates-a-thread-holding-a-pr.md b/changelog.d/runaway-scan-never-rotates-a-thread-holding-a-pr.md new file mode 100644 index 000000000..dbc879ca8 --- /dev/null +++ b/changelog.d/runaway-scan-never-rotates-a-thread-holding-a-pr.md @@ -0,0 +1 @@ +- **`gstack-runaway scan` no longer tells a Captain to rotate a crew thread that holds an open PR.** The scan now matches the rule the docs already carried: only a thread the Captain marks standing (a merge lane, a watcher, an automation's standing thread) gets `FLAG: ROTATE`, at 500M own tokens or 3 active days, and never while it holds an open PR; that case prints `DEFER: holds an open PR; rotate after every PR merges or closes (R91)` instead. Every other thread is a crew thread, which gets one `FLAG: CHECK_IN` past 1 billion own tokens and no rotate or own-token warn line. A thread file whose `pull_requests` list is missing or partial counts as holding a PR, so old or truncated input never leads to a rotation. /runaway-stopper's fetch program gains a `standing` list, its line table gains the `CHECK_IN` row, and `SWEEP:` gains a `check_in=` count. diff --git a/docs/skills.md b/docs/skills.md index c5b46157e..4fe89a9ab 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -969,7 +969,8 @@ sets the defaults that keep it from coming back. - **Detect.** One program fetches each crew thread (`tools.thread.get`, with its transcript) to files, then `gstack-runaway scan --dir ` prints a `RUNAWAY:` line per thread and a `FLAG:` line per limit crossed: own tokens past 500M or active past 3 days (rotate, for a standing thread that wakes on every event; a crew - thread keeps its PR until it merges or closes and checks in with its Captain once past 1B), a subagent past 100M (stop), empty wakes past 25% of the + thread keeps its PR until it merges or closes and checks in with its Captain once past 1B, and no thread holding an + open PR rotates), a subagent past 100M (stop), empty wakes past 25% of the last 40 turns (warm-wait check-ins are left out), and review rounds past 2 per PR. Captains are reported, never rotated. - **Stop.** A live subagent past 100M is stopped with its reason. A standing thread is rotated at a clean point through a handoff file and a successor brief (`runaway-stopper/templates/`), then archived by its own Captain once none of @@ -979,7 +980,8 @@ sets the defaults that keep it from coming back. PR head (metering its own row with a 100M default cap, `BUDGET_DEFAULT_CAP:`), two review rounds, and a fresh thread per automation run. -Scanning FOUN-4, FOUN-14 and FOUN-71 as read on 2026-11-03 (excerpt): +Scanning FOUN-4, FOUN-14 and FOUN-71 as read on 2026-11-03, before R70's 2026-11-09 amendment (today only a thread +marked standing gets `ROTATE`, a crew thread past 1B gets `CHECK_IN`, and a thread holding an open PR never rotates) (excerpt): ``` RUNAWAY: thread=FOUN-4 verdict=ROTATE own=4.04B(upper) total=6.90B credits=2299398 age_days=13.1 active=yes ... diff --git a/lib/runaway.ts b/lib/runaway.ts index 152416255..ad4e3db76 100644 --- a/lib/runaway.ts +++ b/lib/runaway.ts @@ -3,7 +3,9 @@ * Input is what the host already returns: tools.thread.get (cost, tasks, status, created_at), * optionally tools.subagent.list (the whole tree) and transcript items (tools.thread.get transcript, * tools: true). Output is one RUNAWAY line per thread, FLAG lines, a DEFER line when the thread is - * mid-/ship or mid-merge, and a SWEEP total for a directory. It never acts. + * mid-/ship or mid-merge or a standing thread holds an open PR, and a SWEEP total for a directory. It never acts. + * Only a thread the Captain marks `standing: true` rotates; a crew thread checks in once past 1B own tokens (R70 as + * amended 2026-11-09), and a thread holding an open PR, or whose PRs the file doesn't list, is never rotated (R91). */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -12,6 +14,7 @@ import { isKeepalive, type TranscriptItem } from './ship-calls'; export const LIMITS = { ownRotate: 500e6, ownWarn: 400e6, + ownCheckIn: 1e9, ageDays: 3, activeHours: 24, subagentStop: 100e6, @@ -27,18 +30,19 @@ export interface Task { label?: string; path?: string; title?: string; status?: export interface ThreadDoc { code?: string; id?: string; title?: string | null; status?: string; created_at?: string; updated_at?: string; cost?: { tokens?: number; credits?: number }; tasks?: Task[]; tasks_total?: number; captain_thread?: unknown; crew?: unknown; - events?: Item[]; + events?: Item[]; standing?: boolean; pull_requests?: Array<{ number?: number; state?: string }>; pull_requests_total?: number; } export interface Item extends TranscriptItem { input?: { text?: string } | string; state?: string } -export interface Flag { action: 'ROTATE' | 'STOP' | 'LOOK' | 'WARN' | 'REPORT'; text: string } +export interface Flag { action: 'ROTATE' | 'STOP' | 'LOOK' | 'WARN' | 'REPORT' | 'CHECK_IN'; text: string } export interface Verdict { - code: string; verdict: 'OK' | 'LOOK' | 'WARN' | 'STOP' | 'ROTATE' | 'REPORT' | 'DEFER'; captain: boolean; + code: string; verdict: 'OK' | 'LOOK' | 'WARN' | 'STOP' | 'ROTATE' | 'REPORT' | 'CHECK_IN' | 'DEFER'; captain: boolean; own: number; ownBound: 'exact' | 'upper'; total: number; credits: number; ageDays: number; active: boolean; empty: { turns: number; empty: number } | null; rounds: ReturnType; - flags: Flag[]; defer: string[]; + flags: Flag[]; defer: string[]; held: string[]; } const TERMINAL = new Set(['done', 'failed', 'stopped', 'draft']); +const PR_DONE = new Set(['merged', 'closed']); const MID_SHIP = /\/ship|\bship\b|\bmerge\b|\btrain\b|\bstamp\b|pregate/i; const REVIEWISH = /reader|fix-check|cold.?read|panel|outside.?voice|specialist|red.?team|adversarial|review/i; @@ -141,11 +145,31 @@ export function assess(doc: ThreadDoc, opts: { items?: Item[]; subagents?: { tas || (Number.isFinite(lastTurn) && opts.now - lastTurn < LIMITS.activeHours * 36e5); const captain = doc.captain_thread !== undefined || doc.crew !== undefined; const flags: Flag[] = []; - const rotate = (text: string) => flags.push({ action: captain ? 'REPORT' : 'ROTATE', text }); + const held: string[] = []; + const bound = ownBound === 'upper' ? ' (upper bound)' : ''; + const due = [ + own > LIMITS.ownRotate && 'own_tokens ' + fmtTokens(own) + ' > ' + fmtTokens(LIMITS.ownRotate) + bound, + active && ageDays > LIMITS.ageDays && 'age ' + ageDays.toFixed(1) + 'd > ' + LIMITS.ageDays + 'd and active', + ].filter((t): t is string => !!t); + const warn = own <= LIMITS.ownRotate && own > LIMITS.ownWarn ? 'own_tokens ' + fmtTokens(own) + ' > ' + fmtTokens(LIMITS.ownWarn) + ' warn line' : null; + const prs = Array.isArray(doc.pull_requests) ? doc.pull_requests : null; + const openPrs = (prs ?? []).filter(p => !PR_DONE.has(p.state ?? '')).map(p => '#' + (p.number ?? '?')); + const unseen = !prs || (doc.pull_requests_total ?? prs.length) > prs.length; - if (own > LIMITS.ownRotate) rotate('own_tokens ' + fmtTokens(own) + ' > ' + fmtTokens(LIMITS.ownRotate) + (ownBound === 'upper' ? ' (upper bound)' : '')); - else if (own > LIMITS.ownWarn) flags.push({ action: 'WARN', text: 'own_tokens ' + fmtTokens(own) + ' > ' + fmtTokens(LIMITS.ownWarn) + ' warn line' }); - if (active && ageDays > LIMITS.ageDays) rotate('age ' + ageDays.toFixed(1) + 'd > ' + LIMITS.ageDays + 'd and active'); + if (captain) { + for (const text of due) flags.push({ action: 'REPORT', text }); + if (warn) flags.push({ action: 'WARN', text: warn }); + } else if (doc.standing === true) { + if (openPrs.length || unseen) { + if (due.length) held.push((openPrs.length ? 'holds an open PR' : "holds PRs it can't see") + '; rotate after every PR merges or closes (R91): ' + + [...openPrs, ...(unseen ? ['unlisted'] : [])].join(' ') + '; due: ' + due.join(', ')); + } else { + for (const text of due) flags.push({ action: 'ROTATE', text }); + if (warn) flags.push({ action: 'WARN', text: warn }); + } + } else if (own > LIMITS.ownCheckIn) { + flags.push({ action: 'CHECK_IN', text: 'own_tokens ' + fmtTokens(own) + ' > ' + fmtTokens(LIMITS.ownCheckIn) + bound + '; a crew thread checks in with its Captain once and keeps its PR (R70)' }); + } for (const t of listed) { if (tok(t) <= LIMITS.subagentStop) continue; const live = !TERMINAL.has(t.status ?? ''); @@ -160,11 +184,11 @@ export function assess(doc: ThreadDoc, opts: { items?: Item[]; subagents?: { tas const shipping = listed.filter(t => MID_SHIP.test(t.title ?? '') && (t.status === 'working' || (t.status === 'waiting' && /#\d+/.test(t.title ?? '')))); const stopPaths = listed.filter(t => tok(t) > LIMITS.subagentStop && !TERMINAL.has(t.status ?? '')).map(pathOf); const stopsShipping = stopPaths.some(p => shipping.some(t => pathOf(t) === p || pathOf(t).startsWith(p + '.'))); - const defer = shipping.map(t => pathOf(t) + ' ' + quote(t.title) + ' ' + t.status); - const worst = (['ROTATE', 'REPORT', 'STOP', 'WARN', 'LOOK'] as const).find(a => flags.some(f => f.action === a)); + const defer = shipping.map(t => 'mid-ship or mid-merge: ' + pathOf(t) + ' ' + quote(t.title) + ' ' + t.status + '; act at the next clean point'); + const worst = (['ROTATE', 'REPORT', 'STOP', 'WARN', 'CHECK_IN', 'LOOK'] as const).find(a => flags.some(f => f.action === a)); const holds = flags.some(f => f.action === 'ROTATE') || stopsShipping; - const verdict: Verdict['verdict'] = holds && defer.length ? 'DEFER' : (worst ?? 'OK'); - return { code, verdict, captain, own, ownBound, total, credits: doc.cost?.credits ?? 0, ageDays, active, empty, rounds, flags, defer }; + const verdict: Verdict['verdict'] = holds && defer.length ? 'DEFER' : held.length && (!worst || worst === 'LOOK') ? 'DEFER' : (worst ?? 'OK'); + return { code, verdict, captain, own, ownBound, total, credits: doc.cost?.credits ?? 0, ageDays, active, empty, rounds, flags, defer, held }; } export function render(v: Verdict): string[] { @@ -174,7 +198,8 @@ export function render(v: Verdict): string[] { + ' empty_wake=' + e + ' max_rounds=' + (v.rounds?.pr ? v.rounds.rounds + '(#' + v.rounds.pr + ')' : '0') + ' review_subagents=' + (v.rounds?.reviewers ?? 0) + '(unattributed ' + (v.rounds?.unattributed ?? 0) + ')']; for (const f of v.flags) out.push('FLAG: ' + f.action + ' ' + f.text); - if (v.verdict === 'DEFER') for (const d of v.defer) out.push('DEFER: mid-ship or mid-merge: ' + d + '; act at the next clean point'); + if (v.verdict === 'DEFER') for (const d of v.defer) out.push('DEFER: ' + d); + for (const h of v.held) out.push('DEFER: ' + h); return out; } @@ -208,10 +233,13 @@ const USAGE = [ ' 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).', + 'Lines: RUNAWAY (one per thread, largest own tokens first), FLAG ROTATE|STOP|LOOK|WARN|REPORT|CHECK_IN, 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);', - 'empty wakes > 25% of the last 40 turns (20+; warm-wait check-ins left out); review rounds > 2 per PR; Captains REPORT, never rotate.', + 'Limits (R70 as amended 2026-11-09): only a thread file with "standing": true (a merge lane, a watcher, an automation\'s', + 'standing thread) rotates, at own > 500M or active > 3d (warn 400M), and never while it holds an open PR or its', + 'pull_requests are missing or partial (DEFER, R91); any other thread is a crew thread that keeps its PR and gets one', + 'CHECK_IN past 1B own; subagent > 100M stop (LOOK when it already ended); empty wakes > 25% of the last 40 turns', + '(20+; warm-wait check-ins left out); review rounds > 2 per PR; Captains REPORT, never rotate.', 'A thread file that is not valid JSON or lacks cost.tokens or created_at prints RUNAWAY_INPUT and is skipped.', 'Exit 0 after a clean scan, 1 when any input was unreadable, 2 on a usage error.', ]; @@ -278,7 +306,7 @@ export function runawayMain(argv: string[]): number { if (dir) { const n = (k: Verdict['verdict']) => verdicts.filter(v => v.verdict === k).length; console.log('SWEEP: threads=' + verdicts.length + ' rotate=' + n('ROTATE') + ' stop=' + n('STOP') + ' defer=' + n('DEFER') - + ' report=' + n('REPORT') + ' warn=' + n('WARN') + ' look=' + n('LOOK') + ' ok=' + n('OK') + ' unreadable=' + unreadable); + + ' report=' + n('REPORT') + ' warn=' + n('WARN') + ' check_in=' + n('CHECK_IN') + ' look=' + n('LOOK') + ' ok=' + n('OK') + ' unreadable=' + unreadable); } return unreadable ? 1 : 0; } diff --git a/runaway-stopper/SKILL.md b/runaway-stopper/SKILL.md index a06b76ed0..c94d9d91d 100644 --- a/runaway-stopper/SKILL.md +++ b/runaway-stopper/SKILL.md @@ -163,6 +163,7 @@ const max = Math.floor(240 / (pages + 1)); // threads per program, under the 256 const cursor = undefined; // the previous program's NEXT_CURSOR, to continue a large sweep const dir = "/home/user/.capy/work/runaway/" + crypto.randomUUID(); const captain = undefined; // from an automation thread: the Captain's code +const standing = []; // codes that may rotate 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); @@ -176,7 +177,7 @@ const fetch = async code => { ...(i.tool === "wait" ? { input: i.input } : {}) }))); if (!(before = transcript.beforeCursor)) break; } - await tools.capy.write({ path: `${dir}/${doc.code}.thread.json`, content: JSON.stringify({ ...doc, events }) }); + await tools.capy.write({ path: `${dir}/${doc.code}.thread.json`, content: JSON.stringify({ ...doc, events, standing: standing.includes(doc.code) }) }); }; const failed = []; for (let k = 0; k < codes.length; k += 8) (await Promise.allSettled(codes.slice(k, k + 8).map(fetch))) @@ -191,17 +192,19 @@ it lists the whole tree, where thread.get lists 200 rows at most (`own` is then Limits (org rule R70, amended 2026-11-09): a standing thread that wakes on every event (a merge lane, a watcher, an automation's standing thread) rotates past 500M own tokens (total minus subagents) or 3 days active (working, waiting, or a turn in the last 24 hours), warn at 400M. A crew thread keeps its PR until it merges or closes, never -rotated mid-PR on a token count; past 1B own tokens it checks in with its Captain once. A subagent past 100M: stop and look. Empty wakes past 25% of the +rotated mid-PR on a token count; past 1B own tokens it checks in with its Captain once. Open or unknown PRs block rotation +(R91). A subagent past 100M: stop and look. Empty wakes past 25% of the last 40 turns (warm-wait check-ins left out). Review rounds past 2 per PR. Act on each line: | Line | Do | |---|---| -| `FLAG: ROTATE` | A standing thread: Step 3. A crew thread holding a PR: nothing (the scan can't tell them apart). | +| `FLAG: ROTATE` | A standing thread: Step 3. | +| `FLAG: CHECK_IN` | Hear its one check-in; never rotate it. | | `FLAG: REPORT` | A Captain over a limit: report it, never rotate it (Garry decides Captain rotation). | | `FLAG: STOP` | Step 2. | | `FLAG: LOOK` | A finished subagent past 100M: find why (reused for several runs? full-depth review?) and fix that cause from Step 4. | -| `FLAG: WARN` | Tell the thread the Step 4 rule it breaks, once; an own-tokens WARN on a crew thread holding a PR needs nothing. | -| `DEFER:` | The thread is mid-/ship or mid-merge: act on nothing now; scan again when that work finishes. | +| `FLAG: WARN` | Tell the thread the Step 4 rule it breaks, once. | +| `DEFER:` | Mid-/ship, mid-merge or holding a PR: act on nothing; rescan once that ends. | | `RUNAWAY_INPUT:`, `FETCH_FAILED:` | That thread wasn't read: fetch it again and rerun; never count it as OK. | No `RUNAWAY:` or `SWEEP:` line means the scan failed, never that the crew is clean. `DEFER:` holds a rotation, and diff --git a/runaway-stopper/SKILL.md.tmpl b/runaway-stopper/SKILL.md.tmpl index efe81cbb5..40ce382ab 100644 --- a/runaway-stopper/SKILL.md.tmpl +++ b/runaway-stopper/SKILL.md.tmpl @@ -39,6 +39,7 @@ const max = Math.floor(240 / (pages + 1)); // threads per program, under the 256 const cursor = undefined; // the previous program's NEXT_CURSOR, to continue a large sweep const dir = "/home/user/.capy/work/runaway/" + crypto.randomUUID(); const captain = undefined; // from an automation thread: the Captain's code +const standing = []; // codes that may rotate 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); @@ -52,7 +53,7 @@ const fetch = async code => { ...(i.tool === "wait" ? { input: i.input } : {}) }))); if (!(before = transcript.beforeCursor)) break; } - await tools.capy.write({ path: `${dir}/${doc.code}.thread.json`, content: JSON.stringify({ ...doc, events }) }); + await tools.capy.write({ path: `${dir}/${doc.code}.thread.json`, content: JSON.stringify({ ...doc, events, standing: standing.includes(doc.code) }) }); }; const failed = []; for (let k = 0; k < codes.length; k += 8) (await Promise.allSettled(codes.slice(k, k + 8).map(fetch))) @@ -67,17 +68,19 @@ it lists the whole tree, where thread.get lists 200 rows at most (`own` is then Limits (org rule R70, amended 2026-11-09): a standing thread that wakes on every event (a merge lane, a watcher, an automation's standing thread) rotates past 500M own tokens (total minus subagents) or 3 days active (working, waiting, or a turn in the last 24 hours), warn at 400M. A crew thread keeps its PR until it merges or closes, never -rotated mid-PR on a token count; past 1B own tokens it checks in with its Captain once. A subagent past 100M: stop and look. Empty wakes past 25% of the +rotated mid-PR on a token count; past 1B own tokens it checks in with its Captain once. Open or unknown PRs block rotation +(R91). A subagent past 100M: stop and look. Empty wakes past 25% of the last 40 turns (warm-wait check-ins left out). Review rounds past 2 per PR. Act on each line: | Line | Do | |---|---| -| `FLAG: ROTATE` | A standing thread: Step 3. A crew thread holding a PR: nothing (the scan can't tell them apart). | +| `FLAG: ROTATE` | A standing thread: Step 3. | +| `FLAG: CHECK_IN` | Hear its one check-in; never rotate it. | | `FLAG: REPORT` | A Captain over a limit: report it, never rotate it (Garry decides Captain rotation). | | `FLAG: STOP` | Step 2. | | `FLAG: LOOK` | A finished subagent past 100M: find why (reused for several runs? full-depth review?) and fix that cause from Step 4. | -| `FLAG: WARN` | Tell the thread the Step 4 rule it breaks, once; an own-tokens WARN on a crew thread holding a PR needs nothing. | -| `DEFER:` | The thread is mid-/ship or mid-merge: act on nothing now; scan again when that work finishes. | +| `FLAG: WARN` | Tell the thread the Step 4 rule it breaks, once. | +| `DEFER:` | Mid-/ship, mid-merge or holding a PR: act on nothing; rescan once that ends. | | `RUNAWAY_INPUT:`, `FETCH_FAILED:` | That thread wasn't read: fetch it again and rerun; never count it as OK. | No `RUNAWAY:` or `SWEEP:` line means the scan failed, never that the crew is clean. `DEFER:` holds a rotation, and diff --git a/test/runaway.test.ts b/test/runaway.test.ts index 7fe6b1ef8..c76a7d846 100644 --- a/test/runaway.test.ts +++ b/test/runaway.test.ts @@ -33,10 +33,14 @@ const FOUN4 = { task(132, 'Targeted money reader on #501 at 7ed85c37', 374220), ], }; +const FOUN4_STANDING = { ...FOUN4, standing: true, pull_requests: [], pull_requests_total: 0 }; describe('gstack-runaway scan', () => { test('FOUN-4: own tokens and age rotate, the reused /ship subagent is flagged, review rounds counted per PR', () => { - const r = run('scan', '--thread', write('foun4.json', FOUN4), '--now', NOW); + const crew = run('scan', '--thread', write('foun4-crew.json', FOUN4), '--now', NOW).out; + expect(crew).not.toContain('ROTATE'); + expect(crew).toContain('FLAG: CHECK_IN own_tokens 6.06B > 1.00B (upper bound)'); + const r = run('scan', '--thread', write('foun4.json', FOUN4_STANDING), '--now', NOW); expect(r.code).toBe(0); expect(r.out).toContain('RUNAWAY: thread=FOUN-4 verdict=ROTATE'); expect(r.out).toMatch(/own=6\.06B\(upper\)/); @@ -57,7 +61,7 @@ describe('gstack-runaway scan', () => { }); test('DEFER holds a rotation and a stop of the mid-/ship subagent itself; stopping another subagent goes ahead', () => { - const doc = { code: 'GSTA-9', status: 'working', created_at: '2026-11-03T10:00:00Z', cost: { tokens: 300e6 }, + const doc = { code: 'GSTA-9', status: 'working', created_at: '2026-11-03T10:00:00Z', cost: { tokens: 300e6 }, standing: true, pull_requests: [], tasks: [task(3, 'gate fix lane', 150e6, 'idle'), task(4, 'ship 1 outside-voice (#535)', 2e6, 'working')] }; const other = run('scan', '--thread', write('defer.json', doc), '--now', NOW); expect(other.out).toContain('verdict=STOP'); @@ -78,7 +82,7 @@ describe('gstack-runaway scan', () => { }); test('active means working, waiting, or a turn in the last 24 hours; an old quiet thread is not rotated', () => { - const base = { code: 'HARN-11', created_at: '2026-10-25T00:00:00Z', cost: { tokens: 10e6 }, tasks: [] }; + const base = { code: 'HARN-11', created_at: '2026-10-25T00:00:00Z', cost: { tokens: 10e6 }, tasks: [], standing: true, pull_requests: [] }; const idleRecent = run('scan', '--thread', write('idle-recent.json', { ...base, status: 'idle', updated_at: '2026-11-03T21:50:00Z' }), '--now', NOW); expect(idleRecent.out).toContain('active=yes'); expect(idleRecent.out).toContain('FLAG: ROTATE age 9.9d > 3d and active'); @@ -164,7 +168,7 @@ describe('gstack-runaway scan', () => { }); test('a subagent.list file replaces thread.get tasks: own sums top-level paths only, truncation makes it an upper bound', () => { - const doc = { code: 'GSTA-266', status: 'working', created_at: '2026-11-03T20:00:00Z', cost: { tokens: 700e6 }, tasks: [] }; + const doc = { code: 'GSTA-266', status: 'working', created_at: '2026-11-03T20:00:00Z', cost: { tokens: 700e6 }, tasks: [], standing: true, pull_requests: [] }; const subs = { total: 3, tasks: [ { path: '1', title: 'build', status: 'done', cost: { tokens: 150e6, credits: 1 } }, { path: '1.1', title: 'reader', status: 'done', cost: { tokens: 50e6, credits: 1 } }, @@ -181,11 +185,11 @@ describe('gstack-runaway scan', () => { test('--dir sweeps every thread file, largest own tokens first, with a SWEEP total', () => { const d = path.join(tmp, 'crew'); fs.mkdirSync(d); - fs.writeFileSync(path.join(d, 'FOUN-4.thread.json'), JSON.stringify(FOUN4)); + fs.writeFileSync(path.join(d, 'FOUN-4.thread.json'), JSON.stringify(FOUN4_STANDING)); fs.writeFileSync(path.join(d, 'GSTA-1.thread.json'), JSON.stringify({ code: 'GSTA-1', status: 'idle', created_at: '2026-11-03T20:00:00Z', cost: { tokens: 1e6 }, tasks: [] })); const r = run('scan', '--dir', d, '--now', NOW); expect(r.out.indexOf('thread=FOUN-4')).toBeLessThan(r.out.indexOf('thread=GSTA-1')); - expect(r.out).toContain('SWEEP: threads=2 rotate=1 stop=0 defer=0 report=0 warn=0 look=0 ok=1 unreadable=0'); + expect(r.out).toContain('SWEEP: threads=2 rotate=1 stop=0 defer=0 report=0 warn=0 check_in=0 look=0 ok=1 unreadable=0'); expect(r.code).toBe(0); }); @@ -210,7 +214,7 @@ describe('gstack-runaway scan', () => { 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); - fs.writeFileSync(path.join(d, 'FOUN-4.thread.json'), JSON.stringify(FOUN4)); + fs.writeFileSync(path.join(d, 'FOUN-4.thread.json'), JSON.stringify(FOUN4_STANDING)); fs.writeFileSync(path.join(d, 'BAD-1.thread.json'), '{"code": "BAD-1", "cost": '); fs.writeFileSync(path.join(d, 'BAD-2.thread.json'), JSON.stringify({ code: 'BAD-2', status: 'working', tasks: [] })); fs.writeFileSync(path.join(d, 'BAD-3.thread.json'), JSON.stringify({ code: 'BAD-3', cost: { tokens: 1 }, tasks: [] })); @@ -225,7 +229,7 @@ describe('gstack-runaway scan', () => { expect(r.out).toMatch(/BAD-4\.thread\.json field=tasks\[\]\.cost\.tokens expected=number/); expect(r.out).toMatch(/BAD-5\.thread\.json expected a tools\.thread\.get object/); expect(r.out).not.toContain('thread=BAD-2'); - expect(r.out).toContain('rotate=1 stop=0 defer=0 report=0 warn=0 look=0 ok=0 unreadable=5'); + expect(r.out).toContain('rotate=1 stop=0 defer=0 report=0 warn=0 check_in=0 look=0 ok=0 unreadable=5'); }); test('a malformed --subagents file is named as RUNAWAY_INPUT, not scanned as exact', () => { @@ -273,6 +277,40 @@ describe('gstack-runaway scan', () => { expect(run('scan', '--dir', path.join(tmp, 'nope')).err).toContain('RUNAWAY_USAGE: no directory'); expect(run('scan', '--thread', write('ok4.json', FOUN4), '--event', 'x.json').err).toContain('RUNAWAY_USAGE: unknown argument "--event"'); }); + // Value: protects=R70 as amended 2026-11-09 and R91: a crew thread keeps its PR to the end and checks in once at 1B own tokens; only a + // standing thread with no open PR rotates; unknown PR data counts as holding one; fails_when=scan tells a Captain to rotate a crew + // thread that holds an open PR (GST-2226); seam=assess via gstack-runaway scan; no_claim=whether a thread is standing (the Captain says so) + test('a crew thread holding a PR never rotates, checks in at 1B, and only a standing thread with no open PR rotates', () => { + const pr = (state: string) => ({ repo: 'garrytan/gstack', number: 4300, title: 'x', state, relation: 'owner' }); + const base = { code: 'GSTA-500', status: 'working', created_at: '2026-11-03T10:00:00Z', tasks: [] }; + const crew = run('scan', '--thread', write('crew-pr.json', { ...base, cost: { tokens: 600e6 }, pull_requests: [pr('open')], pull_requests_total: 1 }), '--now', NOW).out; + expect(crew).not.toContain('ROTATE'); + expect(crew).not.toContain('FLAG: WARN'); + expect(crew).toContain('verdict=OK'); + const big = run('scan', '--thread', write('crew-big.json', { ...base, cost: { tokens: 1.1e9 }, pull_requests: [pr('open')], pull_requests_total: 1 }), '--now', NOW).out; + expect(big).toContain('verdict=CHECK_IN'); + expect(big.match(/^FLAG: /gm)).toEqual(['FLAG: ']); + expect(big).toContain('FLAG: CHECK_IN own_tokens 1.10B > 1.00B'); + const old = run('scan', '--thread', write('crew-old.json', { ...base, created_at: '2026-10-25T00:00:00Z', cost: { tokens: 10e6 }, pull_requests: [] }), '--now', NOW).out; + expect(old).toContain('verdict=OK'); + const standing = { ...base, cost: { tokens: 600e6 }, standing: true, pull_requests: [pr('merged'), pr('closed')], pull_requests_total: 2 }; + const s = run('scan', '--thread', write('standing.json', standing), '--now', NOW).out; + expect(s).toContain('verdict=ROTATE'); + expect(s).toContain('FLAG: ROTATE own_tokens 600M > 500M'); + const aged = run('scan', '--thread', write('standing-aged.json', { ...standing, cost: { tokens: 10e6 }, created_at: '2026-10-25T00:00:00Z' }), '--now', NOW).out; + expect(aged).toContain('FLAG: ROTATE age 9.9d > 3d and active'); + expect(run('scan', '--thread', write('standing-warn.json', { ...standing, cost: { tokens: 450e6 } }), '--now', NOW).out).toContain('FLAG: WARN own_tokens 450M > 400M warn line'); + for (const [name, prs] of [['open', { pull_requests: [pr('open')], pull_requests_total: 1 }], ['draft', { pull_requests: [pr('draft')] }], + ['unlisted', { pull_requests: [pr('merged')], pull_requests_total: 2 }], ['unknown', { pull_requests: undefined }]] as const) { + const held = run('scan', '--thread', write('standing-' + name + '.json', { ...standing, ...prs }), '--now', NOW).out; + expect(held).not.toContain('ROTATE'); + expect(held).not.toContain('FLAG: WARN own_tokens'); + expect(held).toContain('verdict=DEFER'); + expect(held).toMatch(/DEFER: holds (an open PR|PRs it can't see); rotate after every PR merges or closes \(R91\)/); + } + const unknownCrew = run('scan', '--thread', write('crew-unknown.json', { ...base, cost: { tokens: 600e6 } }), '--now', NOW).out; + expect(unknownCrew).not.toContain('ROTATE'); + }); }); describe('/runaway-stopper skill', () => { @@ -310,6 +348,12 @@ describe('/runaway-stopper skill', () => { expect(out).toContain('empty_wake=0%/20'); expect(out).not.toContain('FLAG: WARN empty_wake'); }); + test('the fetch program marks standing threads, and only a standing thread with no open PR is rotated', () => { + expect(skill).toContain('content: JSON.stringify({ ...doc, events, standing: standing.includes(doc.code) })'); + expect(skill).toContain('| `FLAG: ROTATE` | A standing thread: Step 3. |'); + expect(skill).toContain('| `FLAG: CHECK_IN` |'); + expect(skill).toContain('Open or unknown PRs block rotation\n(R91)'); + }); test('rotation stops or lists live subagents, the successor subscribes to open PRs, and archiving waits for it', () => { expect(skill).toContain('stop your working and waiting subagents, or list each with its run'); expect(skill).toContain('subscribes to each open PR in the handoff');