Skip to content

Commit a21ed61

Browse files
committed
fix: bound long-running TUI agents with a wall-clock ceiling so a busy-but-stuck agent can't run unbounded
The idle-timeout reaper resets on every PTY chunk, but Claude Code repaints its (Ns · esc to interrupt) working counter ~1x/sec while any tool/API call is in flight — including one stuck retrying a stalled Bedrock/network operation. A busy-but-stuck agent therefore keeps lastOutputAt advancing forever, so idle-reap never fires and the run had no ceiling at all: agent-b1c56083's /do:next --swarm claim-issue orchestration churned its counter for 98 minutes before Claude Code's own internal 'Operation timed out' finally stopped it. Had the CLI not self-terminated, the agent would have run unbounded, holding its lane, blocking the app cooldown, and leaking a shell session. The long-running agent TUI path now arms an absolute wall-clock backstop from prompt submission (default 3h, provider-configurable via tuiMaxRuntimeMs), mirroring the one-shot runner's existing hardTimeoutTimer. On fire it salvages as success if .agent-done exists, else finalizes as a needs-manual-finish max-runtime-timeout failure with a captured worktree diff.
1 parent 6828a05 commit a21ed61

7 files changed

Lines changed: 168 additions & 6 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- **A live agent TUI no longer gets reaped mid-paste.** The idle-timeout reaper only tracked `lastOutputAt` (PTY output), not client input, so pasting a large multi-line command into a running agent's session could sit in a silent reflow/commit window with no echo yet — indistinguishable from "idle" to the 5s poll. If the session happened to already be near its idle threshold, the reaper killed the PTY mid-paste, showing "[Session removed externally]" in the Shell page. The idle timer in `agentTuiSpawning.js` now also checks a new `shell.js` export, `getLastInputAt()`, and skips the reap while input arrived within the last 15s. This is deliberately keyed on input *recency*, not "is a Shell viewer attached": a regular (non-external) session's socket stays bound after its viewer navigates away — only external one-shot `tuiPromptRunner.js` runs get released via `shell:release-views` — so an attachment-based guard would have permanently suppressed idle-complete for any agent glanced at once in the Shell UI. (`server/services/shell.js`, `server/services/agentTuiSpawning.js`)
99
- **Swarm claim-issue runs now write the completion sentinel so the task actually finishes.** In `/do:next --swarm` mode the orchestrator ships every issue via a PR and never touches the working tree, so it was skipping the `.agent-done` completion sentinel — leaving the CoS task hanging with no summary as if the run never ended. Phase C of the swarm directive now explicitly instructs the orchestrator to write the sentinel (with a run summary of the issues claimed, their PRs, and merge outcomes) after the serialized merges, while still skipping the per-agent `/simplify`/push steps. (`server/services/cosTaskGenerator.js`)
1010
- **[issue-2190] OpenCode Ollama providers now accept any --model flag.** The static `OPENCODE_CONFIG_CONTENT` lacked a models map, so OpenCode rejected every model with "not valid" and the agent sat idle. The config is now built dynamically at spawn time, declaring the provider's configured models (plus the model being run) under `provider.ollama.models` with bare ids — the exact shape OpenCode's `@ai-sdk/openai-compatible` provider validates `--model ollama/<id>` against. Applied across every OpenCode spawn path (CoS CLI/TUI agents, the toolkit CLI/TUI runners, and the light fire-and-collect CLI run), and a migration backfills existing installs' `data/providers.json` so a configured model's map is persisted too. Fixes the TUI eval path for local Ollama models.
11+
- **A busy-but-stuck TUI agent can no longer run unbounded — a wall-clock ceiling now backstops the idle reaper.** The idle-timeout reaper resets on every PTY chunk, but Claude Code repaints its `(Ns · esc to interrupt)` working counter roughly once a second while *any* tool/API call is in flight — including one stuck retrying a stalled Bedrock/network operation. A "busy-but-stuck" agent therefore keeps `lastOutputAt` advancing forever, so idle-reap never fires and the run had no ceiling at all: a real `/do:next --swarm` claim-issue orchestration churned its counter for 98 minutes (2026-07-06, agent-b1c56083) before Claude Code's *own* internal "Operation timed out" finally stopped it — had the CLI not self-terminated, the agent would have run unbounded, holding its lane, blocking the app's cooldown, and leaking a shell session. The long-running agent TUI path now arms an absolute wall-clock backstop from prompt submission (default 3h, provider-configurable via `tuiMaxRuntimeMs`), mirroring the one-shot runner's existing `hardTimeoutTimer`. If it fires with no `.agent-done` sentinel present, the run finalizes as a needs-manual-finish FAILURE (`max-runtime-timeout`) with the same "check for open or merged-but-uncleaned PRs" guidance as the merge-queue/review-loop idle-timeout paths, capturing any uncommitted worktree diff for post-mortem first; if the sentinel *is* present the run is salvaged as success. (`server/services/agentTuiSpawning.js`, `server/lib/tuiHandshake.js`, `server/lib/aiToolkit/providers.js`, `server/lib/aiToolkit/validation.js`, `server/lib/validation.js`)
1112
- **[issue-2191] TUI agents with zero file changes are now marked failed instead of silently succeeding.** A TUI agent that idled out after showing working indicators but made no uncommitted file changes was previously finalized as `success: idle-complete` — deleting the worktree and any evidence of what happened. Now the idle-complete path checks `git status` in the worktree: if the tree is clean, it finalizes as `failure: idle-no-changes` with the TUI buffer tail as error context. Any uncommitted diff is also captured to `worktree-diff.txt` in the agent archive dir before cleanup, so post-mortems can see what (if anything) was left behind.
1213
- **[issue-2193] Concurrent agent spawns no longer race on git worktree creation.** When two tasks for the same app spawned in the same evaluation tick, the first `git worktree add` grabbed git's per-repo worktree lock and the second failed with a lock error — blocking that task with `blockedCategory: worktree-failed` and leaving an orphan branch (`cos/task-…/agent-…`) with no worktree. Worktree creation now serializes per source repo through a single promise tail (the `issueWriteTail` pattern), so same-tick adds queue instead of colliding; `git worktree add` also retries a few times with backoff on lock errors to absorb residual external contention; and on final failure the partially-created branch is deleted (`git branch -D` + `git worktree prune`) so orphan branches stop accumulating. (`server/services/worktreeManager.js`)
1314
- **Scheduled improvement tasks with no configured model now follow the active provider's default instead of a stale hardcoded one.** When a self-improvement or managed-app improvement task had no explicit model in its schedule config, the generator pinned a hardcoded `claude-opus-4-5-20251101` into the task — a model that had since dropped out of the `claude-code-tui` provider config, so the scheduler spawned `claude --model claude-opus-4-5-20251101` even after the provider default moved to Opus 4.8. Since a per-task model pin is honored above the provider default (it's passed straight through to the CLI), that stale literal overrode the configured default on every scheduled run. The generator no longer pins a literal: with no configured model it leaves the task's model unset, so model selection resolves the active provider's tier/default model at spawn time and always uses a model the provider actually lists. An explicit per-task or per-schedule model pin still wins as before. (`server/services/cosTaskGenerator.js`)

server/lib/aiToolkit/providers.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,11 @@ export function createProviderService(config = {}) {
449449
secretEnvVars: providerData.secretEnvVars || [],
450450
headlessArgs: providerData.headlessArgs || [],
451451
tuiPromptDelayMs: providerData.tuiPromptDelayMs || 2500,
452-
tuiIdleTimeoutMs: providerData.tuiIdleTimeoutMs || 180000
452+
tuiIdleTimeoutMs: providerData.tuiIdleTimeoutMs || 180000,
453+
// Absolute wall-clock ceiling for long-running TUI agents (3h). The idle
454+
// reaper can't bound a busy-but-stuck agent whose working counter keeps
455+
// repainting; the consumer (agentTuiSpawning) enforces this backstop.
456+
tuiMaxRuntimeMs: providerData.tuiMaxRuntimeMs || 10800000
453457
};
454458

455459
data.providers[id] = provider;

server/lib/aiToolkit/validation.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ export const providerSchema = z.object({
8686
secretEnvVars: z.array(z.string()).optional(),
8787
headlessArgs: z.array(z.string()).optional(),
8888
tuiPromptDelayMs: z.number().int().min(250).max(60000).optional(),
89-
tuiIdleTimeoutMs: z.number().int().min(10000).max(1800000).optional()
89+
tuiIdleTimeoutMs: z.number().int().min(10000).max(1800000).optional(),
90+
// Absolute wall-clock ceiling for a long-running TUI agent (idle-reap can't
91+
// bound a busy-but-stuck agent — see DEFAULT_TUI_MAX_RUNTIME_MS). Min 1min,
92+
// max 12h to cover the longest legitimate multi-hour orchestration.
93+
tuiMaxRuntimeMs: z.number().int().min(60000).max(43200000).optional()
9094
});
9195

9296
export const runSchema = z.object({

server/lib/tuiHandshake.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,24 @@ export function scheduleSubmitEnters(write, isFinalized) {
457457
export const DEFAULT_TUI_PROMPT_DELAY_MS = 2500;
458458
export const DEFAULT_TUI_IDLE_TIMEOUT_MS = 180000;
459459

460+
// Absolute wall-clock ceiling for a long-running TUI agent, applied from prompt
461+
// submission. This is the honest backstop the idle reaper CAN'T be: the reaper
462+
// resets on every PTY chunk, but Claude Code repaints its `(Ns · …)` working
463+
// counter ~1×/sec while ANY tool/API call is in flight — INCLUDING one stuck
464+
// retrying a stalled Bedrock/network operation. A "busy-but-stuck" agent keeps
465+
// `lastOutputAt` advancing forever, so idle-reap never fires and the run has NO
466+
// ceiling at all (real incident 2026-07-06: agent-b1c56083 churned the counter
467+
// for 98min on a `/do:next --swarm` claim-issue task before Claude Code's OWN
468+
// internal "Operation timed out" finally stopped it — had the CLI not self-
469+
// terminated, the agent would have run unbounded, holding a lane, blocking the
470+
// app's cooldown, and leaking a shell session). The one-shot runner already has
471+
// this backstop (tuiPromptRunner.js `hardTimeoutTimer`); the agent path omitted
472+
// it. 3h sits comfortably above the longest legitimate single run observed
473+
// (swarm claim-issue orchestrations routinely take 40–98min, and the merge-queue
474+
// / review-loop idle windows are 15min each) while bounding a genuinely-stuck
475+
// agent. Provider-configurable via `tuiMaxRuntimeMs`.
476+
export const DEFAULT_TUI_MAX_RUNTIME_MS = 3 * 60 * 60 * 1000;
477+
460478
// Extended idle threshold applied ONLY while a `/do:next --swarm` orchestrator
461479
// is in its Phase C serialized merge queue (issue #2074). Merging PRs one at a
462480
// time makes each subsequent PR rebase onto the new `main` and re-run required

server/lib/validation.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ export const providerSchema = z.object({
202202
envVars: z.record(z.string()).optional(),
203203
headlessArgs: z.array(z.string()).optional(),
204204
tuiPromptDelayMs: z.number().int().min(250).max(60000).optional(),
205-
tuiIdleTimeoutMs: z.number().int().min(10000).max(1800000).optional()
205+
tuiIdleTimeoutMs: z.number().int().min(10000).max(1800000).optional(),
206+
// Absolute wall-clock ceiling for long-running TUI agents (mirrors the
207+
// aiToolkit providerSchema; the idle reaper can't bound a busy-but-stuck agent
208+
// — see DEFAULT_TUI_MAX_RUNTIME_MS in tuiHandshake.js). Min 1min, max 12h.
209+
tuiMaxRuntimeMs: z.number().int().min(60000).max(43200000).optional()
206210
});
207211

208212
// Run command schema

server/services/agentTuiSpawning.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { isAntigravityCommand } from '../lib/antigravity.js';
2626
import {
2727
DEFAULT_TUI_PROMPT_DELAY_MS,
2828
DEFAULT_TUI_IDLE_TIMEOUT_MS,
29+
DEFAULT_TUI_MAX_RUNTIME_MS,
2930
MERGE_QUEUE_IDLE_TIMEOUT_MS,
3031
REVIEW_LOOP_IDLE_TIMEOUT_MS,
3132
READY_POLL_INTERVAL_MS,
@@ -272,7 +273,8 @@ export function buildTuiSpawnConfig(provider, model, { systemPromptFile = null }
272273
args,
273274
commandLine: [command, ...args].map(shellQuote).join(' '),
274275
promptDelayMs: provider?.tuiPromptDelayMs || DEFAULT_TUI_PROMPT_DELAY_MS,
275-
idleTimeoutMs: provider?.tuiIdleTimeoutMs || DEFAULT_TUI_IDLE_TIMEOUT_MS
276+
idleTimeoutMs: provider?.tuiIdleTimeoutMs || DEFAULT_TUI_IDLE_TIMEOUT_MS,
277+
maxRuntimeMs: provider?.tuiMaxRuntimeMs || DEFAULT_TUI_MAX_RUNTIME_MS
276278
};
277279
}
278280

@@ -398,6 +400,12 @@ export async function spawnTuiAgent({
398400
let pasteEnterTimer = null;
399401
let pasteVerifyTimer = null;
400402
let submitEnterTimer = null;
403+
// Absolute wall-clock backstop — armed once the prompt is SUBMITTED, cleared
404+
// in finish(). Unlike the idle reaper (which resets on every PTY repaint and
405+
// so never fires for a busy-but-stuck agent whose working counter keeps
406+
// ticking), this bounds the total run so a hung provider/CLI can't run
407+
// unbounded. See DEFAULT_TUI_MAX_RUNTIME_MS for the incident.
408+
let maxRuntimeTimer = null;
401409

402410
const streamingStrip = createStreamingAnsiStripper();
403411

@@ -549,6 +557,7 @@ export async function spawnTuiAgent({
549557
if (pasteEnterTimer) { clearInterval(pasteEnterTimer); pasteEnterTimer = null; }
550558
if (pasteVerifyTimer) { clearInterval(pasteVerifyTimer); pasteVerifyTimer = null; }
551559
if (submitEnterTimer) { clearInterval(submitEnterTimer); submitEnterTimer = null; }
560+
if (maxRuntimeTimer) { clearTimeout(maxRuntimeTimer); maxRuntimeTimer = null; }
552561
// Release the post-paste accumulator even when finalize fires mid-paste-
553562
// window. The pasteEnterTimer's own cleanup path nulls this too, but if
554563
// finalize comes from elsewhere (shell-exit, command-not-found, user
@@ -901,6 +910,41 @@ export async function spawnTuiAgent({
901910
() => shellService.writeToSession(sessionId, '\r'),
902911
() => finalized
903912
);
913+
// Arm the absolute wall-clock backstop from submission (once — a paste
914+
// retry re-enters submitEnter but must not stack timers). The idle reaper
915+
// can't bound a busy-but-stuck agent because Claude Code's working counter
916+
// keeps repainting through a stalled provider retry, resetting lastOutputAt
917+
// forever; this timer is the honest ceiling regardless of PTY chatter.
918+
if (!maxRuntimeTimer) {
919+
maxRuntimeTimer = setTimeout(() => {
920+
if (finalized) return;
921+
// Salvage net: if the agent already wrote its .agent-done sentinel the
922+
// run truly finished (the TUI just never idled/exited), so complete as
923+
// success — mirrors the one-shot runner's response-file salvage. The
924+
// 2s doneSentinelTimer normally catches this first; this covers the
925+
// boundary where it lands right at the deadline.
926+
const salvaged = doneSentinelPath && existsSync(doneSentinelPath);
927+
if (salvaged) {
928+
finish({ success: true, exitCode: 0, reason: 'max-runtime-sentinel' }).catch(err => {
929+
emitLog('error', `Failed to finalize TUI agent ${agentId} at max-runtime salvage: ${err.message}`, { agentId });
930+
});
931+
return;
932+
}
933+
// Capture any uncommitted work for post-mortem before cleanup, then
934+
// fail with a needs-manual-finish message — a stuck orchestrator may
935+
// have left PRs/worktrees behind (same recovery guidance as the
936+
// merge-queue/review-loop idle-timeout paths).
937+
captureWorktreeDiff(cwd, agentDir).catch(() => {});
938+
finish({
939+
success: false,
940+
exitCode: 124,
941+
error: `TUI agent exceeded its max runtime of ${Math.round(tuiConfig.maxRuntimeMs / 60000)}min — the provider/CLI likely hung (a stalled request keeps the working counter repainting so the idle reaper never fires); check for open or merged-but-uncleaned PRs and finish them manually.`,
942+
reason: 'max-runtime-timeout',
943+
}).catch(err => {
944+
emitLog('error', `Failed to finalize TUI agent ${agentId} at max-runtime: ${err.message}`, { agentId });
945+
});
946+
}, tuiConfig.maxRuntimeMs);
947+
}
904948
};
905949

906950
// Confirms the TUI actually received the paste before we submit. The
@@ -1233,7 +1277,8 @@ export async function spawnTuiAgent({
12331277
tuiSessionId: sessionId,
12341278
tuiCommand: tuiConfig.commandLine,
12351279
tuiKind,
1236-
tuiIdleTimeoutMs: tuiConfig.idleTimeoutMs
1280+
tuiIdleTimeoutMs: tuiConfig.idleTimeoutMs,
1281+
tuiMaxRuntimeMs: tuiConfig.maxRuntimeMs
12371282
}
12381283
});
12391284

server/services/agentTuiSpawning.test.js

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,10 @@ describe('spawnTuiAgent runtime', () => {
317317
args: [],
318318
commandLine: 'codex',
319319
promptDelayMs: 100,
320-
idleTimeoutMs: 50
320+
idleTimeoutMs: 50,
321+
// Large so the wall-clock backstop never fires during the modest fake-timer
322+
// advances the idle/paste tests perform (the max-runtime test overrides it).
323+
maxRuntimeMs: 3600000
321324
};
322325

323326
function runSpawn(overrides = {}) {
@@ -621,6 +624,89 @@ describe('spawnTuiAgent runtime', () => {
621624
);
622625
});
623626

627+
// ── 1c. Absolute wall-clock backstop reaps a busy-but-stuck agent ───────────
628+
// The idle reaper resets on every PTY chunk, so an agent whose working counter
629+
// keeps repainting through a stalled provider retry never idles out and would
630+
// run unbounded (real incident 2026-07-06: agent-b1c56083 churned for 98min).
631+
// The max-runtime timer is the honest ceiling: it fires from submission
632+
// regardless of PTY chatter and, with no .agent-done sentinel present,
633+
// finalizes as a needs-manual-finish FAILURE.
634+
it('max-runtime: reaps a still-chattering agent as failure once the wall-clock ceiling elapses', async () => {
635+
let resolveComplete;
636+
const completeDone = new Promise((r) => { resolveComplete = r; });
637+
vi.mocked(agentLifecycle.finalizeAgent).mockImplementation(async () => { resolveComplete(); });
638+
639+
// Idle window LARGER than the max-runtime ceiling so the idle reaper can't
640+
// win — this isolates the wall-clock backstop (the real-world stuck agent
641+
// keeps its working counter ticking, so idle never fires anyway).
642+
runSpawn({ tuiConfig: { ...defaultTuiConfig, idleTimeoutMs: 600000, maxRuntimeMs: 30000 } });
643+
await flushMicrotasks();
644+
645+
await capturedOnData(Buffer.from('Codex booting...\n'));
646+
await flushMicrotasks();
647+
await vi.advanceTimersByTimeAsync(2000);
648+
await flushMicrotasks();
649+
650+
// Prompt echo → paste verification passes → submit-Enter fires → the
651+
// max-runtime timer is armed.
652+
await capturedOnData(Buffer.from('do the thing\n'));
653+
await flushMicrotasks();
654+
await vi.advanceTimersByTimeAsync(3600);
655+
await flushMicrotasks();
656+
657+
// A busy agent that keeps chattering — but the idle window (600s) is huge so
658+
// only the 30s wall-clock ceiling can reap it. Advance past the ceiling.
659+
await capturedOnData(Buffer.from('(1s · thinking with high effort)\n'));
660+
await vi.advanceTimersByTimeAsync(31000);
661+
await flushMicrotasks();
662+
663+
vi.useRealTimers();
664+
await completeDone;
665+
666+
expect(agentLifecycle.finalizeAgent).toHaveBeenCalledWith(
667+
expect.objectContaining({
668+
agentId: 'agent-1',
669+
success: false,
670+
completionReason: 'max-runtime-timeout',
671+
})
672+
);
673+
});
674+
675+
// ── 1d. A written .agent-done sentinel is never overridden by a FAILURE reap ─
676+
// If the agent wrote .agent-done, the run truly finished — the max-runtime
677+
// ceiling firing would be a false failure. The 2s sentinel poll normally
678+
// finalizes it as success first; the max-runtime timer's own salvage branch
679+
// (existsSync check) is the boundary backstop mirroring the one-shot runner's
680+
// response-file salvage. Either way, with the sentinel present the run must
681+
// finalize as SUCCESS — never as a max-runtime FAILURE.
682+
it('max-runtime does not fail a run whose .agent-done sentinel exists', async () => {
683+
vi.mocked(existsSync).mockReturnValue(true);
684+
685+
let resolveComplete;
686+
const completeDone = new Promise((r) => { resolveComplete = r; });
687+
vi.mocked(agentLifecycle.finalizeAgent).mockImplementation(async () => { resolveComplete(); });
688+
689+
runSpawn({ tuiConfig: { ...defaultTuiConfig, maxRuntimeMs: 30000 } });
690+
await flushMicrotasks();
691+
692+
await capturedOnData(Buffer.from('Codex booting...\n'));
693+
await flushMicrotasks();
694+
await vi.advanceTimersByTimeAsync(2000);
695+
await flushMicrotasks();
696+
697+
await capturedOnData(Buffer.from('do the thing\n'));
698+
await flushMicrotasks();
699+
await vi.advanceTimersByTimeAsync(3600);
700+
await flushMicrotasks();
701+
702+
vi.useRealTimers();
703+
await completeDone;
704+
705+
const call = vi.mocked(agentLifecycle.finalizeAgent).mock.calls.at(-1)?.[0];
706+
expect(call?.success).toBe(true);
707+
expect(call?.completionReason).not.toBe('max-runtime-timeout');
708+
});
709+
624710
// ── 1b. Command exited before the prompt → don't paste into the bare shell ───
625711
// The TUI command (claude/codex/…) runs as a CHILD of the persistent PTY
626712
// shell, so if it exits at startup the PTY stays open and onExit never fires.

0 commit comments

Comments
 (0)