Skip to content

v0.6.1 — Dual timeout for stuck agents#3

Merged
b1rdmania merged 3 commits into
mainfrom
feat/v0.6.1-dual-timeout
Mar 19, 2026
Merged

v0.6.1 — Dual timeout for stuck agents#3
b1rdmania merged 3 commits into
mainfrom
feat/v0.6.1-dual-timeout

Conversation

@b1rdmania

@b1rdmania b1rdmania commented Mar 19, 2026

Copy link
Copy Markdown
Owner

Problem

Agents were hanging for 30-40 minutes before being killed. The single 30-min timer only reset on complete output marker pairs — a genuinely stuck agent (hung waiting on an API, no stdout at all) sat there until the full timer expired.

Solution

Two independent timers replace the single one:

Timer Default Resets on Kills when
Idle (AGENT_IDLE_TIMEOUT) 10 min Any stdout activity No stdout for 10 min → stuck
Absolute (AGENT_ABSOLUTE_TIMEOUT) 45 min Never Hard ceiling regardless of activity

Both are configurable via env vars. Both log their reason (idle vs absolute) for easier post-mortems.

What changes

  • src/config.ts — two new exported constants
  • src/container-runner.tsidleTimer (reset on stdout data) + absoluteTimer (never reset) replace the old single timeout
  • src/container-runner.test.ts — updated tests + new "stdout resets idle timer" test (449 passing)

Test plan

  • Send /update in Telegram — bot pulls and rebuilds
  • Trigger a normal task — completes within 10 min, no spurious timeout
  • Confirm logs show idle or absolute as reason on any timeouts
  • Check .env — can override with AGENT_IDLE_TIMEOUT=300000 (5 min) or AGENT_ABSOLUTE_TIMEOUT=3600000 (60 min) if needed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Agents now use two timeouts: an idle timeout (resets on stdout activity, default 10 minutes) and an absolute ceiling (never resets, default 45 minutes). Logging now records whether termination was due to idle or absolute timeout.
  • Tests

    • Updated tests to cover idle reset behavior and absolute-timeout scenarios.
  • Chores

    • Version bumped and changelog updated for 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>
@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@b1rdmania has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 29 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f144494-2f11-4bd4-aedf-1a03da1f5e59

📥 Commits

Reviewing files that changed from the base of the PR and between 0b6d33f and cb7c7aa.

📒 Files selected for processing (2)
  • src/container-runner.test.ts
  • src/container-runner.ts
📝 Walkthrough

Walkthrough

This change replaces a single 30-minute timeout with two concurrent timers for agent containers: an idle timeout (default 10 min, resets on stdout activity) and an absolute ceiling (default 45 min, never resets). Config, runner logic, tests, changelog, and package version updated.

Changes

Cohort / File(s) Summary
Release & Metadata
CHANGELOG.md, package.json
Added v0.6.1 changelog entry describing the dual-timeout behavior; bumped package version to 0.6.1.
Configuration
src/config.ts
Added exported AGENT_IDLE_TIMEOUT and AGENT_ABSOLUTE_TIMEOUT constants (defaults: 600000 ms and 2700000 ms).
Container Runner Implementation
src/container-runner.ts
Replaced single timeout with two timers (idle + absolute). Idle timer resets on stdout data; absolute never resets. Added timeoutReason handling and killOnTimeout(reason). Clear both timers on close/error.
Tests
src/container-runner.test.ts
Updated tests to use new timeout constants, split timeout behavior into separate idle and absolute tests, and added test verifying stdout activity resets the idle timer.

Sequence Diagram(s)

sequenceDiagram
    participant Runner as Runner
    participant Agent as Agent Process
    participant IdleTimer as Idle Timer
    participant AbsoluteTimer as Absolute Timer
    participant Logger as Logger

    Runner->>Agent: start agent
    Runner->>IdleTimer: start (resets on stdout)
    Runner->>AbsoluteTimer: start (never resets)
    Agent-->>Runner: stdout data
    Runner->>IdleTimer: reset
    Note right of AbsoluteTimer: continues counting
    IdleTimer->>Runner: idle timeout elapsed (if no stdout)
    Runner->>Logger: log "terminated (idle)"
    Runner->>Agent: SIGTERM / SIGKILL (if needed)
    AbsoluteTimer->>Runner: absolute timeout elapsed (if reached)
    Runner->>Logger: log "terminated (absolute)"
    Runner->>Agent: SIGTERM / SIGKILL (if needed)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Two timers in the burrow, one naps when we speak,
The other counts onward, steady and meek.
Stdout gives a twitch — the idle one restarts,
While absolute watches, unbothered by arts.
Hooray for tidy timeouts and brave little parts!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'v0.6.1 — Dual timeout for stuck agents' directly and clearly summarizes the main change: introducing a dual timeout mechanism to handle stuck agents, which is the primary focus of all changes across CHANGELOG, config, and container-runner files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v0.6.1-dual-timeout
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/container-runner.ts (1)

437-441: ⚠️ Potential issue | 🟡 Minor

Error message reports configTimeout but could be misleading.

When the idle timeout fires, the error message reports configTimeout (potentially 30min) rather than the actual idle timeout (10min) that triggered the kill. This could confuse post-mortem analysis.

Proposed fix

Consider tracking which timeout fired and using the appropriate duration in the error message:

+        const actualTimeout = hadStreamingOutput ? absoluteTimeoutMs : idleTimeoutMs;
         resolve({
           status: 'error',
           result: null,
-          error: `Agent timed out after ${configTimeout}ms`,
+          error: `Agent timed out after ${actualTimeout}ms (${hadStreamingOutput ? 'absolute' : 'idle'})`,
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/container-runner.ts` around lines 437 - 441, The error message uses
configTimeout even when the idle timer caused the kill; update the
timeout-handling in the function around the resolve({...}) call so it tracks
which timer fired (e.g., set a variable/enum like timeoutType or lastTimeout and
store the duration that triggered the stop) and then use that tracked value and
label in the error string instead of always using configTimeout; locate the idle
timeout handler and the config timeout handler in container-runner (references:
configTimeout, idleTimeout, and the resolve({ status: 'error', result: null,
error: ... }) invocation) and set the appropriate duration/label before calling
resolve so the message reflects the actual timeout that fired.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/container-runner.ts`:
- Around line 437-441: The error message uses configTimeout even when the idle
timer caused the kill; update the timeout-handling in the function around the
resolve({...}) call so it tracks which timer fired (e.g., set a variable/enum
like timeoutType or lastTimeout and store the duration that triggered the stop)
and then use that tracked value and label in the error string instead of always
using configTimeout; locate the idle timeout handler and the config timeout
handler in container-runner (references: configTimeout, idleTimeout, and the
resolve({ status: 'error', result: null, error: ... }) invocation) and set the
appropriate duration/label before calling resolve so the message reflects the
actual timeout that fired.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 313b8cee-2b5f-483f-a113-b03b715556a6

📥 Commits

Reviewing files that changed from the base of the PR and between d030b4a and a7cde58.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • package.json
  • src/config.ts
  • src/container-runner.test.ts
  • src/container-runner.ts

Track timeoutReason ('idle' | 'absolute') and use the corresponding
duration in the error string and log, instead of always reporting
configTimeout regardless of which timer fired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/container-runner.ts (1)

426-449: ⚠️ Potential issue | 🟠 Major

Don’t turn a timed-out streamed run into success.

hadStreamingOutput only means one marker pair parsed, not that a terminal success was seen. If the agent streams partial output and then hits a timeout, this branch resolves { status: 'success' }, so the timeout disappears in src/index.ts:306-336 and src/task-scheduler.ts:230-265, which only inspect the final output.status/error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/container-runner.ts` around lines 426 - 449, The current timeout branch
treats hadStreamingOutput as a terminal success (in the if (hadStreamingOutput)
block) which hides timeouts; update that branch so after logging you wait for
outputChain to finish but then resolve with a timeout error (status: 'error',
include error message `Agent timed out after ${timeoutMs}ms (${timeoutLabel})`)
instead of returning status: 'success'; preserve newSessionId in the error
payload if needed and keep the existing logger.info call but do not convert a
partial streamed run into success—use the same error shape used in the
non-streaming timeout branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/container-runner.ts`:
- Around line 364-368: The code currently uses Math.max(configTimeout,
AGENT_ABSOLUTE_TIMEOUT) so any smaller AGENT_ABSOLUTE_TIMEOUT (or smaller
group.containerConfig.timeout) is ignored; change absoluteTimeoutMs to use
Math.min(configTimeout, AGENT_ABSOLUTE_TIMEOUT) and then compute idleTimeoutMs
as Math.min(AGENT_IDLE_TIMEOUT, absoluteTimeoutMs) (referencing
absoluteTimeoutMs, idleTimeoutMs, configTimeout, AGENT_ABSOLUTE_TIMEOUT,
AGENT_IDLE_TIMEOUT) so both absolute and idle ceilings can be lowered by config
or env.
- Around line 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.

---

Outside diff comments:
In `@src/container-runner.ts`:
- Around line 426-449: The current timeout branch treats hadStreamingOutput as a
terminal success (in the if (hadStreamingOutput) block) which hides timeouts;
update that branch so after logging you wait for outputChain to finish but then
resolve with a timeout error (status: 'error', include error message `Agent
timed out after ${timeoutMs}ms (${timeoutLabel})`) instead of returning status:
'success'; preserve newSessionId in the error payload if needed and keep the
existing logger.info call but do not convert a partial streamed run into
success—use the same error shape used in the non-streaming timeout branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92fa1b63-a3f0-45e8-8334-580a2ea972ec

📥 Commits

Reviewing files that changed from the base of the PR and between a7cde58 and 0b6d33f.

📒 Files selected for processing (1)
  • src/container-runner.ts

Comment thread src/container-runner.ts Outdated
Comment thread src/container-runner.ts
Comment on lines +370 to 382
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);

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.

- Math.min for both timeouts so config can lower below the defaults
  (absoluteTimeoutMs = min(configTimeout, AGENT_ABSOLUTE_TIMEOUT),
   idleTimeoutMs = min(AGENT_IDLE_TIMEOUT, absoluteTimeoutMs))
- One-shot kill guard: timeoutHandled boolean prevents double-fire
  when idle and absolute timers both trigger
- Timeout always resolves as error, even when hadStreamingOutput is true
  — hides the timeout from callers; index.ts outputSentToUser guard
  already prevents incorrect cursor rollback in this case
- Update test: 'absolute timeout after output' now asserts error status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@b1rdmania b1rdmania merged commit 7855e82 into main Mar 19, 2026
2 checks passed
@b1rdmania b1rdmania deleted the feat/v0.6.1-dual-timeout branch March 23, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant