runaway: warm-wait check-ins are not empty wakes (GST-1597) (#4069)

* runaway: warm-wait check-ins are not empty wakes (GST-1597)

lib/runaway.ts counted each 240-second warm-wait check-in (docs/captain.md,
#4060) as an empty wake, so /runaway-stopper could flag a lane that follows
the rule. A turn whose only call re-issues the wait on the same operation, or
a 240-second timer, within 30 minutes of the wait that started the run is
now left out of the rate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/specialist-batched#1 reader=red-team
Review-Finding: p1/specialist-batched#2 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: note the warm-wait exclusion in the /runaway-stopper summary; rewrap captain.md (GST-1597 docs audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* runaway: keep wait input in the skill collector; 240 s timers chain only on the run's operations; status reads keep the warm window (GST-1597)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

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-06 13:40:49 +00:00
1 parent bd36aa0b46
commit ff65fb0c48
7 files changed
+120 -12

No files matched your search

+30 -3
View File
@@ -18,6 +18,8 @@ export const LIMITS = {
emptyRate: 0.25,
emptyWindow: 40,
emptyMinTurns: 20,
warmWaitS: 240,
warmWindowS: 30 * 60,
maxRounds: 2,
} as const;
@@ -51,18 +53,43 @@ function reportState(item: Item): string | undefined {
return raw?.match(/\\?"state\\?"\s*:\s*\\?"(final|needs_you|working)/)?.[1];
}
/** A wait call's operations and timer, from its recorded input; null when the input is unreadable. */
function waitArgs(item: Item): { ops: string; seconds: number | null } | null {
const raw = typeof item.input === 'string' ? item.input : item.input?.text;
try {
const v = JSON.parse(raw ?? '') as { operation_ids?: unknown; duration_seconds?: unknown };
return { ops: Array.isArray(v.operation_ids) ? [...v.operation_ids].map(String).sort().join(',') : '', seconds: typeof v.duration_seconds === 'number' ? v.duration_seconds : null };
} catch { return null; }
}
/**
* Turns from transcript items: an inbound message starts one; end_turn, wait or a final/needs_you report
* ends one. A turn is empty when it made no call beyond status reads, waits and "working" reports.
* ends one. A turn is empty when it made no call beyond status reads, waits and "working" reports. A warm-wait
* check-in (docs/captain.md, Warm waits; GST-1597) is not a turn: one whose only call re-issues a wait on the
* operations the previous turn waited on, or a 240-second timer naming those operations or none, within 30 minutes
* of the wait that started the run. Any turn that closes on such a wait keeps the run's start, so a status read
* between check-ins counts but never restarts the 30 minutes.
*/
export function emptyWakes(items: readonly Item[]): { turns: number; empty: number } {
const sorted = items.filter(i => i.createdAt && i.source !== 'assistant').sort((a, b) => a.createdAt!.localeCompare(b.createdAt!));
const turns: boolean[] = [];
let acted = false, open = false;
const close = () => { if (open) turns.push(!acted); acted = false; open = false; };
let calls: Item[] = [];
let last: { ops: string; since: number } | null = null;
const close = () => {
if (!open) return;
const closing = calls.at(-1);
const args = closing?.tool === 'wait' ? waitArgs(closing) : null;
const at = Date.parse(closing?.createdAt ?? '');
const same = !!args && !!last && (args.ops ? args.ops === last.ops : args.seconds === LIMITS.warmWaitS);
if (!(same && calls.length === 1 && at - last!.since <= LIMITS.warmWindowS * 1000)) turns.push(!acted);
last = args ? (same ? last : { ops: args.ops, since: at }) : null;
acted = false; open = false; calls = [];
};
for (const i of sorted) {
if (i.source !== 'tool') { close(); continue; }
open = true;
calls.push(i);
const state = i.tool === 'message_agent' || i.tool === 'message_user' ? reportState(i) : undefined;
if (i.tool !== 'end_turn' && state !== 'working' && !isKeepalive(i)) acted = true;
if (i.tool === 'end_turn' || i.tool === 'wait' || state === 'final' || state === 'needs_you') close();
@@ -184,7 +211,7 @@ const USAGE = [
'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);',
'empty wakes > 25% of the last 40 turns (20+); review rounds > 2 per PR; Captains REPORT, never rotate.',
'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.',
];