Skip to content

Commit a7cde58

Browse files
b1rdmaniaclaude
andcommitted
feat: dual timeout for stuck agents (v0.6.1)
Replace the single 30-min timer with two independent timers: - Idle timeout (AGENT_IDLE_TIMEOUT, default 10 min): reset on any stdout activity. Agent with no stdout for 10 min is considered stuck → killed. Catches the hung-waiting-on-API pattern that caused 30-40 min hangs. - Absolute ceiling (AGENT_ABSOLUTE_TIMEOUT, default 45 min): never resets. Hard cap regardless of activity. Prevents runaway agents producing garbage output from running forever. Both timers log their reason (idle/absolute) for easier post-mortems. Both values are configurable via env vars. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d030b4a commit a7cde58

5 files changed

Lines changed: 86 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## v0.6.1 (2026-03-19) — Dual timeout for stuck agents
4+
5+
### Fixes
6+
- **Dual agent timeout** replaces the old single 30-min timer:
7+
- **Idle timeout** (10 min default, `AGENT_IDLE_TIMEOUT` env var): reset on any stdout activity. An agent that produces no output for 10 minutes is considered stuck and killed. This catches the "hung waiting on API" case that was causing 30-40 min hangs.
8+
- **Absolute ceiling** (45 min default, `AGENT_ABSOLUTE_TIMEOUT` env var): never resets, regardless of any activity. Hard cap for runaway agents producing garbage output.
9+
- Both timeouts log their reason (`idle` vs `absolute`) to make post-mortems easier.
10+
311
## v0.6.0 (2026-03-19) — Reliability + remote control
412

513
### Fixes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ghostclaw",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "Personal AI assistant. Bare metal, Telegram-first, no containers.",
55
"type": "module",
66
"main": "dist/index.js",

src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
3939
); // 10MB default
4040
export const IPC_POLL_INTERVAL = 1000;
4141
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
42+
43+
// Agent process timeouts (two independent timers in container-runner)
44+
// Idle: reset on any stdout activity. Agent with no stdout for this long is stuck → kill.
45+
export const AGENT_IDLE_TIMEOUT = parseInt(
46+
process.env.AGENT_IDLE_TIMEOUT || '600000',
47+
10,
48+
); // 10 min default
49+
// Absolute: never resets. Hard ceiling regardless of any activity.
50+
export const AGENT_ABSOLUTE_TIMEOUT = parseInt(
51+
process.env.AGENT_ABSOLUTE_TIMEOUT || '2700000',
52+
10,
53+
); // 45 min default
4254
export const MAX_CONCURRENT_CONTAINERS = Math.max(
4355
1,
4456
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,

src/container-runner.test.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ vi.mock('./config.js', () => ({
1414
DATA_DIR: '/tmp/ghostclaw-test-data',
1515
GROUPS_DIR: '/tmp/ghostclaw-test-groups',
1616
IDLE_TIMEOUT: 1800000, // 30min
17+
AGENT_IDLE_TIMEOUT: 5000, // 5s in tests
18+
AGENT_ABSOLUTE_TIMEOUT: 10000, // 10s in tests
1719
TIMEZONE: 'America/Los_Angeles',
1820
}));
1921

@@ -116,7 +118,7 @@ describe('container-runner timeout behavior', () => {
116118
vi.useRealTimers();
117119
});
118120

119-
it('timeout after output resolves as success', async () => {
121+
it('absolute timeout after output resolves as success', async () => {
120122
const onOutput = vi.fn(async () => {});
121123
const resultPromise = runContainerAgent(
122124
testGroup,
@@ -125,7 +127,7 @@ describe('container-runner timeout behavior', () => {
125127
onOutput,
126128
);
127129

128-
// Emit output with a result
130+
// Emit output with a result (also resets idle timer via stdout data)
129131
emitOutputMarker(fakeProc, {
130132
status: 'success',
131133
result: 'Here is my response',
@@ -135,13 +137,12 @@ describe('container-runner timeout behavior', () => {
135137
// Let output processing settle
136138
await vi.advanceTimersByTimeAsync(10);
137139

138-
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
139-
await vi.advanceTimersByTimeAsync(1830000);
140+
// Fire the absolute ceiling (AGENT_ABSOLUTE_TIMEOUT = 10000ms in tests)
141+
await vi.advanceTimersByTimeAsync(10000);
140142

141-
// Emit close event (as if container was stopped by the timeout)
143+
// Emit close event (as if process was stopped by the timeout)
142144
fakeProc.emit('close', 137);
143145

144-
// Let the promise resolve
145146
await vi.advanceTimersByTimeAsync(10);
146147

147148
const result = await resultPromise;
@@ -152,7 +153,7 @@ describe('container-runner timeout behavior', () => {
152153
);
153154
});
154155

155-
it('timeout with no output resolves as error', async () => {
156+
it('idle timeout with no stdout resolves as error', async () => {
156157
const onOutput = vi.fn(async () => {});
157158
const resultPromise = runContainerAgent(
158159
testGroup,
@@ -161,12 +162,10 @@ describe('container-runner timeout behavior', () => {
161162
onOutput,
162163
);
163164

164-
// No output emitted — fire the hard timeout
165-
await vi.advanceTimersByTimeAsync(1830000);
165+
// No stdout at all — fire the idle timeout (AGENT_IDLE_TIMEOUT = 5000ms in tests)
166+
await vi.advanceTimersByTimeAsync(5000);
166167

167-
// Emit close event
168168
fakeProc.emit('close', 137);
169-
170169
await vi.advanceTimersByTimeAsync(10);
171170

172171
const result = await resultPromise;
@@ -175,6 +174,32 @@ describe('container-runner timeout behavior', () => {
175174
expect(onOutput).not.toHaveBeenCalled();
176175
});
177176

177+
it('stdout activity resets idle timer', async () => {
178+
const onOutput = vi.fn(async () => {});
179+
const resultPromise = runContainerAgent(
180+
testGroup,
181+
testInput,
182+
() => {},
183+
onOutput,
184+
);
185+
186+
// Emit raw stdout at 4s — just before idle timeout (5s in tests)
187+
await vi.advanceTimersByTimeAsync(4000);
188+
fakeProc.stdout.push('thinking...\n');
189+
190+
// Idle timer should have reset — advance another 4s (total 8s, but idle only 4s since last stdout)
191+
await vi.advanceTimersByTimeAsync(4000);
192+
193+
// Agent hasn't timed out yet — now emit proper output and close normally
194+
emitOutputMarker(fakeProc, { status: 'success', result: 'Done' });
195+
await vi.advanceTimersByTimeAsync(10);
196+
fakeProc.emit('close', 0);
197+
await vi.advanceTimersByTimeAsync(10);
198+
199+
const result = await resultPromise;
200+
expect(result.status).toBe('success');
201+
});
202+
178203
it('normal exit after output resolves as success', async () => {
179204
const onOutput = vi.fn(async () => {});
180205
const resultPromise = runContainerAgent(

src/container-runner.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import fs from 'fs';
77
import path from 'path';
88

99
import {
10+
AGENT_ABSOLUTE_TIMEOUT,
11+
AGENT_IDLE_TIMEOUT,
1012
CONTAINER_MAX_OUTPUT_SIZE,
1113
CONTAINER_TIMEOUT,
1214
DATA_DIR,
1315
GROUPS_DIR,
14-
IDLE_TIMEOUT,
1516
} from './config.js';
1617
import { readEnvFile } from './env.js';
1718
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
@@ -291,6 +292,7 @@ export async function runContainerAgent(
291292

292293
agentProcess.stdout.on('data', (data) => {
293294
const chunk = data.toString();
295+
resetIdleTimer();
294296

295297
if (!stdoutTruncated) {
296298
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
@@ -324,7 +326,6 @@ export async function runContainerAgent(
324326
newSessionId = parsed.newSessionId;
325327
}
326328
hadStreamingOutput = true;
327-
resetTimeout();
328329
outputChain = outputChain.then(() => onOutput(parsed));
329330
} catch (err) {
330331
logger.warn(
@@ -359,31 +360,42 @@ export async function runContainerAgent(
359360
let timedOut = false;
360361
let hadStreamingOutput = false;
361362
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
362-
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
363+
const idleTimeoutMs = Math.min(
364+
AGENT_IDLE_TIMEOUT,
365+
Math.max(configTimeout, AGENT_ABSOLUTE_TIMEOUT),
366+
);
367+
const absoluteTimeoutMs = Math.max(configTimeout, AGENT_ABSOLUTE_TIMEOUT);
363368

364-
const killOnTimeout = () => {
369+
const killOnTimeout = (reason: 'idle' | 'absolute') => {
365370
timedOut = true;
366371
logger.error(
367-
{ group: group.name, processName },
368-
'Agent timeout, killing process',
372+
{ group: group.name, processName, reason },
373+
reason === 'idle'
374+
? 'Agent idle timeout — no stdout for too long, killing process'
375+
: 'Agent absolute timeout — hard ceiling reached, killing process',
369376
);
370377
agentProcess.kill('SIGTERM');
371378
setTimeout(() => {
372-
if (!agentProcess.killed) {
373-
agentProcess.kill('SIGKILL');
374-
}
379+
if (!agentProcess.killed) agentProcess.kill('SIGKILL');
375380
}, 15000);
376381
};
377382

378-
let timeout = setTimeout(killOnTimeout, timeoutMs);
379-
380-
const resetTimeout = () => {
381-
clearTimeout(timeout);
382-
timeout = setTimeout(killOnTimeout, timeoutMs);
383+
// Idle timer — reset on any stdout activity
384+
let idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
385+
const resetIdleTimer = () => {
386+
clearTimeout(idleTimer);
387+
idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
383388
};
384389

390+
// Absolute ceiling — never resets
391+
const absoluteTimer = setTimeout(
392+
() => killOnTimeout('absolute'),
393+
absoluteTimeoutMs,
394+
);
395+
385396
agentProcess.on('close', (code) => {
386-
clearTimeout(timeout);
397+
clearTimeout(idleTimer);
398+
clearTimeout(absoluteTimer);
387399
const duration = Date.now() - startTime;
388400

389401
if (timedOut) {
@@ -557,7 +569,8 @@ export async function runContainerAgent(
557569
});
558570

559571
agentProcess.on('error', (err) => {
560-
clearTimeout(timeout);
572+
clearTimeout(idleTimer);
573+
clearTimeout(absoluteTimer);
561574
logger.error(
562575
{ group: group.name, processName, error: err },
563576
'Agent spawn error',

0 commit comments

Comments
 (0)