Skip to content

Commit 7855e82

Browse files
authored
Merge pull request #3 from b1rdmania/feat/v0.6.1-dual-timeout
v0.6.1 — Dual timeout for stuck agents
2 parents d030b4a + cb7c7aa commit 7855e82

5 files changed

Lines changed: 118 additions & 48 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: 39 additions & 13 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 error', 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,24 +137,24 @@ 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;
148-
expect(result.status).toBe('success');
149-
expect(result.newSessionId).toBe('session-123');
149+
// Timeout always resolves as error — index.ts outputSentToUser guard prevents cursor rollback
150+
expect(result.status).toBe('error');
151+
expect(result.error).toContain('timed out');
150152
expect(onOutput).toHaveBeenCalledWith(
151153
expect.objectContaining({ result: 'Here is my response' }),
152154
);
153155
});
154156

155-
it('timeout with no output resolves as error', async () => {
157+
it('idle timeout with no stdout resolves as error', async () => {
156158
const onOutput = vi.fn(async () => {});
157159
const resultPromise = runContainerAgent(
158160
testGroup,
@@ -161,12 +163,10 @@ describe('container-runner timeout behavior', () => {
161163
onOutput,
162164
);
163165

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

167-
// Emit close event
168169
fakeProc.emit('close', 137);
169-
170170
await vi.advanceTimersByTimeAsync(10);
171171

172172
const result = await resultPromise;
@@ -175,6 +175,32 @@ describe('container-runner timeout behavior', () => {
175175
expect(onOutput).not.toHaveBeenCalled();
176176
});
177177

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

src/container-runner.ts

Lines changed: 58 additions & 34 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(
@@ -357,33 +358,53 @@ export async function runContainerAgent(
357358
});
358359

359360
let timedOut = false;
361+
let timeoutReason: 'idle' | 'absolute' | null = null;
360362
let hadStreamingOutput = false;
361363
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
362-
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
364+
const absoluteTimeoutMs = Math.min(configTimeout, AGENT_ABSOLUTE_TIMEOUT);
365+
const idleTimeoutMs = Math.min(AGENT_IDLE_TIMEOUT, absoluteTimeoutMs);
363366

364-
const killOnTimeout = () => {
367+
let timeoutHandled = false;
368+
const killOnTimeout = (reason: 'idle' | 'absolute') => {
369+
if (timeoutHandled) return;
370+
timeoutHandled = true;
365371
timedOut = true;
372+
timeoutReason = reason;
366373
logger.error(
367-
{ group: group.name, processName },
368-
'Agent timeout, killing process',
374+
{ group: group.name, processName, reason },
375+
reason === 'idle'
376+
? 'Agent idle timeout — no stdout for too long, killing process'
377+
: 'Agent absolute timeout — hard ceiling reached, killing process',
369378
);
370379
agentProcess.kill('SIGTERM');
380+
agentProcess.once('close', () => {
381+
// Process already dead, nothing more to do
382+
});
371383
setTimeout(() => {
372-
if (!agentProcess.killed) {
384+
try {
373385
agentProcess.kill('SIGKILL');
386+
} catch {
387+
// already dead
374388
}
375389
}, 15000);
376390
};
377391

378-
let timeout = setTimeout(killOnTimeout, timeoutMs);
379-
380-
const resetTimeout = () => {
381-
clearTimeout(timeout);
382-
timeout = setTimeout(killOnTimeout, timeoutMs);
392+
// Idle timer — reset on any stdout activity
393+
let idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
394+
const resetIdleTimer = () => {
395+
clearTimeout(idleTimer);
396+
idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
383397
};
384398

399+
// Absolute ceiling — never resets
400+
const absoluteTimer = setTimeout(
401+
() => killOnTimeout('absolute'),
402+
absoluteTimeoutMs,
403+
);
404+
385405
agentProcess.on('close', (code) => {
386-
clearTimeout(timeout);
406+
clearTimeout(idleTimer);
407+
clearTimeout(absoluteTimer);
387408
const duration = Date.now() - startTime;
388409

389410
if (timedOut) {
@@ -402,30 +423,32 @@ export async function runContainerAgent(
402423
].join('\n'),
403424
);
404425

405-
if (hadStreamingOutput) {
406-
logger.info(
407-
{ group: group.name, processName, duration, code },
408-
'Agent timed out after output (idle cleanup)',
409-
);
410-
outputChain.then(() => {
411-
resolve({
412-
status: 'success',
413-
result: null,
414-
newSessionId,
415-
});
416-
});
417-
return;
418-
}
426+
const timeoutMs =
427+
timeoutReason === 'idle' ? idleTimeoutMs : absoluteTimeoutMs;
428+
const timeoutLabel =
429+
timeoutReason === 'idle'
430+
? `idle timeout (${idleTimeoutMs}ms no stdout)`
431+
: `absolute timeout (${absoluteTimeoutMs}ms ceiling)`;
419432

420433
logger.error(
421-
{ group: group.name, processName, duration, code },
422-
'Agent timed out with no output',
434+
{
435+
group: group.name,
436+
processName,
437+
duration,
438+
code,
439+
timeoutReason,
440+
hadStreamingOutput,
441+
},
442+
`Agent timed out (${timeoutLabel})`,
423443
);
424444

425-
resolve({
426-
status: 'error',
427-
result: null,
428-
error: `Agent timed out after ${configTimeout}ms`,
445+
outputChain.then(() => {
446+
resolve({
447+
status: 'error',
448+
result: null,
449+
error: `Agent timed out after ${timeoutMs}ms (${timeoutLabel})`,
450+
newSessionId,
451+
});
429452
});
430453
return;
431454
}
@@ -557,7 +580,8 @@ export async function runContainerAgent(
557580
});
558581

559582
agentProcess.on('error', (err) => {
560-
clearTimeout(timeout);
583+
clearTimeout(idleTimer);
584+
clearTimeout(absoluteTimer);
561585
logger.error(
562586
{ group: group.name, processName, error: err },
563587
'Agent spawn error',

0 commit comments

Comments
 (0)