Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## v0.6.1 (2026-03-19) — Dual timeout for stuck agents

### Fixes
- **Dual agent timeout** replaces the old single 30-min timer:
- **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.
- **Absolute ceiling** (45 min default, `AGENT_ABSOLUTE_TIMEOUT` env var): never resets, regardless of any activity. Hard cap for runaway agents producing garbage output.
- Both timeouts log their reason (`idle` vs `absolute`) to make post-mortems easier.

## v0.6.0 (2026-03-19) — Reliability + remote control

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghostclaw",
"version": "0.6.0",
"version": "0.6.1",
"description": "Personal AI assistant. Bare metal, Telegram-first, no containers.",
"type": "module",
"main": "dist/index.js",
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
); // 10MB default
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result

// Agent process timeouts (two independent timers in container-runner)
// Idle: reset on any stdout activity. Agent with no stdout for this long is stuck → kill.
export const AGENT_IDLE_TIMEOUT = parseInt(
process.env.AGENT_IDLE_TIMEOUT || '600000',
10,
); // 10 min default
// Absolute: never resets. Hard ceiling regardless of any activity.
export const AGENT_ABSOLUTE_TIMEOUT = parseInt(
process.env.AGENT_ABSOLUTE_TIMEOUT || '2700000',
10,
); // 45 min default
export const MAX_CONCURRENT_CONTAINERS = Math.max(
1,
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
Expand Down
47 changes: 36 additions & 11 deletions src/container-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ghostclaw-test-data',
GROUPS_DIR: '/tmp/ghostclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
AGENT_IDLE_TIMEOUT: 5000, // 5s in tests
AGENT_ABSOLUTE_TIMEOUT: 10000, // 10s in tests
TIMEZONE: 'America/Los_Angeles',
}));

Expand Down Expand Up @@ -116,7 +118,7 @@ describe('container-runner timeout behavior', () => {
vi.useRealTimers();
});

it('timeout after output resolves as success', async () => {
it('absolute timeout after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(
testGroup,
Expand All @@ -125,7 +127,7 @@ describe('container-runner timeout behavior', () => {
onOutput,
);

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

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

// Emit close event (as if container was stopped by the timeout)
// Emit close event (as if process was stopped by the timeout)
fakeProc.emit('close', 137);

// Let the promise resolve
await vi.advanceTimersByTimeAsync(10);

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

it('timeout with no output resolves as error', async () => {
it('idle timeout with no stdout resolves as error', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(
testGroup,
Expand All @@ -161,12 +162,10 @@ describe('container-runner timeout behavior', () => {
onOutput,
);

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

// Emit close event
fakeProc.emit('close', 137);

await vi.advanceTimersByTimeAsync(10);

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

it('stdout activity resets idle timer', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(
testGroup,
testInput,
() => {},
onOutput,
);

// Emit raw stdout at 4s — just before idle timeout (5s in tests)
await vi.advanceTimersByTimeAsync(4000);
fakeProc.stdout.push('thinking...\n');

// Idle timer should have reset — advance another 4s (total 8s, but idle only 4s since last stdout)
await vi.advanceTimersByTimeAsync(4000);

// Agent hasn't timed out yet — now emit proper output and close normally
emitOutputMarker(fakeProc, { status: 'success', result: 'Done' });
await vi.advanceTimersByTimeAsync(10);
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);

const result = await resultPromise;
expect(result.status).toBe('success');
});

it('normal exit after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(
Expand Down
45 changes: 29 additions & 16 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import fs from 'fs';
import path from 'path';

import {
AGENT_ABSOLUTE_TIMEOUT,
AGENT_IDLE_TIMEOUT,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
} from './config.js';
import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
Expand Down Expand Up @@ -291,6 +292,7 @@ export async function runContainerAgent(

agentProcess.stdout.on('data', (data) => {
const chunk = data.toString();
resetIdleTimer();

if (!stdoutTruncated) {
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
Expand Down Expand Up @@ -324,7 +326,6 @@ export async function runContainerAgent(
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
resetTimeout();
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
Expand Down Expand Up @@ -359,31 +360,42 @@ export async function runContainerAgent(
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const idleTimeoutMs = Math.min(
AGENT_IDLE_TIMEOUT,
Math.max(configTimeout, AGENT_ABSOLUTE_TIMEOUT),
);
const absoluteTimeoutMs = Math.max(configTimeout, AGENT_ABSOLUTE_TIMEOUT);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const killOnTimeout = () => {
const killOnTimeout = (reason: 'idle' | 'absolute') => {
timedOut = true;
logger.error(
{ group: group.name, processName },
'Agent timeout, killing process',
{ group: group.name, processName, reason },
reason === 'idle'
? 'Agent idle timeout — no stdout for too long, killing process'
: 'Agent absolute timeout — hard ceiling reached, killing process',
);
agentProcess.kill('SIGTERM');
setTimeout(() => {
if (!agentProcess.killed) {
agentProcess.kill('SIGKILL');
}
if (!agentProcess.killed) agentProcess.kill('SIGKILL');
}, 15000);
Comment on lines +368 to 389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Node.js, what does ChildProcess.killedmean? Does it becometruewhenkill() successfully sends a signal, or only after the child process has actually exited?

💡 Result:

In Node.js, ChildProcess.killed is a boolean property that becomes true when the subprocess.kill method is called successfully on that specific ChildProcess instance (i.e., when the underlying kill operation returns success, such as libuv error code 0). It does not become true if the process is killed externally (e.g., via process.kill(child.pid) or OS kill command), nor does it wait for the child process to actually exit. The property indicates that a kill signal was sent via the ChildProcess's kill method, not that the process has terminated. The child may continue running after killed is set to true, until it handles the signal and exits. To check if the process has exited, use the 'exit' or 'close' events, child.exitCode !== null, or signalCode. kill itself returns true if the signal send succeeded (ESRCH if already dead), but killed is set only on success before exit.

Citations:


🏁 Script executed:

cd /repo && sed -n '350,420p' src/container-runner.ts

Repository: b1rdmania/ghostclaw

Length of output: 118


🏁 Script executed:

pwd && find . -name "container-runner.ts" -type f 2>/dev/null | head -5

Repository: b1rdmania/ghostclaw

Length of output: 107


🏁 Script executed:

sed -n '350,420p' ./src/container-runner.ts

Repository: b1rdmania/ghostclaw

Length of output: 2452


🏁 Script executed:

sed -n '400,430p' ./src/container-runner.ts

Repository: b1rdmania/ghostclaw

Length of output: 1205


Make timeout termination one-shot and track real process exit.

ChildProcess.killed becomes true when kill() successfully sends the signal, not when the child has exited. The 15s fallback check if (!agentProcess.killed) will always skip SIGKILL since killed was already set to true by the initial SIGTERM call—even if the process is still running. Use the 'close' event to track actual termination.

Additionally, killOnTimeout() is re-entrant: both the idle and absolute timers can fire independently and call it, allowing the second call to overwrite timeoutReason before the 'close' handler reads it.

Suggested fix
+    let exited = false;
+
     const killOnTimeout = (reason: 'idle' | 'absolute') => {
+      if (timedOut) return;
       timedOut = true;
       timeoutReason = reason;
+      clearTimeout(idleTimer);
+      clearTimeout(absoluteTimer);
       logger.error(
         { group: group.name, processName, reason },
         reason === 'idle'
@@ -381,7 +388,7 @@
       );
       agentProcess.kill('SIGTERM');
       setTimeout(() => {
-        if (!agentProcess.killed) agentProcess.kill('SIGKILL');
+        if (!exited) agentProcess.kill('SIGKILL');
       }, 15000);
     };
 
@@ -399,6 +406,7 @@
     agentProcess.on('close', (code) => {
+      exited = true;
       clearTimeout(idleTimer);
       clearTimeout(absoluteTimer);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const killOnTimeout = (reason: 'idle' | 'absolute') => {
timedOut = true;
timeoutReason = reason;
logger.error(
{ group: group.name, processName },
'Agent timeout, killing process',
{ group: group.name, processName, reason },
reason === 'idle'
? 'Agent idle timeout — no stdout for too long, killing process'
: 'Agent absolute timeout — hard ceiling reached, killing process',
);
agentProcess.kill('SIGTERM');
setTimeout(() => {
if (!agentProcess.killed) {
agentProcess.kill('SIGKILL');
}
if (!agentProcess.killed) agentProcess.kill('SIGKILL');
}, 15000);
let exited = false;
const killOnTimeout = (reason: 'idle' | 'absolute') => {
if (timedOut) return;
timedOut = true;
timeoutReason = reason;
clearTimeout(idleTimer);
clearTimeout(absoluteTimer);
logger.error(
{ group: group.name, processName, reason },
reason === 'idle'
? 'Agent idle timeout — no stdout for too long, killing process'
: 'Agent absolute timeout — hard ceiling reached, killing process',
);
agentProcess.kill('SIGTERM');
setTimeout(() => {
if (!exited) agentProcess.kill('SIGKILL');
}, 15000);
};
// ... (additional code between killOnTimeout and the close handler)
agentProcess.on('close', (code) => {
exited = true;
clearTimeout(idleTimer);
clearTimeout(absoluteTimer);
// ... (rest of close handler)
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/container-runner.ts` around lines 370 - 382, The timeout handler
killOnTimeout should be made one-shot and should track real process exit via the
child process 'close' event instead of relying on ChildProcess.killed; update
killOnTimeout (and any surrounding scope) to guard with a boolean like
timeoutHandled so only the first invocation sets timedOut and timeoutReason and
performs agentProcess.kill('SIGTERM'), then attach a one-time
agentProcess.once('close', ...) listener to detect actual exit and only then (if
still running) escalate to SIGKILL after a 15s timer; remove checks against
agentProcess.killed and ensure the 'close' handler reads the preserved
timeoutReason to log the final outcome.

};

let timeout = setTimeout(killOnTimeout, timeoutMs);

const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
// Idle timer — reset on any stdout activity
let idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
const resetIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => killOnTimeout('idle'), idleTimeoutMs);
};

// Absolute ceiling — never resets
const absoluteTimer = setTimeout(
() => killOnTimeout('absolute'),
absoluteTimeoutMs,
);

agentProcess.on('close', (code) => {
clearTimeout(timeout);
clearTimeout(idleTimer);
clearTimeout(absoluteTimer);
const duration = Date.now() - startTime;

if (timedOut) {
Expand Down Expand Up @@ -557,7 +569,8 @@ export async function runContainerAgent(
});

agentProcess.on('error', (err) => {
clearTimeout(timeout);
clearTimeout(idleTimer);
clearTimeout(absoluteTimer);
logger.error(
{ group: group.name, processName, error: err },
'Agent spawn error',
Expand Down
Loading