Skip to content

bug: Idle-agent GC (2 min, hardcoded) kills agent runtimes that still have in-flight background work #2444

Description

@slazarov

What's broken

Idle-agent GC (2 min, hardcoded) kills agent runtimes that still have in-flight background work

Summary

Since 0.2.0, the daemon garbage-collects idle agent runtimes after a hardcoded
2-minute TTL. The collectability test only considers paseo's notion of activity
(foreground turn, paseo runs, pending permissions) and has no visibility into work the
underlying agent CLI is running on its own — Claude Code background Bash commands,
Monitor watches, background subagents.

The result: a live agent process is killed while it still has pending work, the work is
lost, and nothing surfaces the loss. From the agent's perspective the absence of
further events is indistinguishable from "still running", so it waits indefinitely on
something that no longer exists.

The TTL and sweep interval are module constants with no config key and no environment
override, so this cannot be tuned or disabled.

To be clear about what is not broken: session continuity is fine. The runtime is
respawned with --resume=<same session id>, the transcript file is appended (not
duplicated), and no conversation state is lost. The problems are (a) silently killed
background work and (b) the prompt-cache cost of each respawn.

Environment

@getpaseo/cli 0.2.2
@getpaseo/server 0.2.2
Previously working on 0.1.110 (no 0.2.0 betas)
Agent provider Claude Code 2.1.220, claude-opus-5[1m]
@anthropic-ai/claude-agent-sdk ^0.3.220
Node v24.16.0
OS Ubuntu 26.04 LTS, kernel 7.0.0-27-generic

Root cause

dist/server/server/bootstrap.js:149-150

const IDLE_AGENT_RUNTIME_TTL_MS            = 2 * 60 * 1000;  // 2 minutes
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS =     15 * 1000;  // sweep every 15s

bootstrap.js:850-879 schedules an unconditional sweep (setInterval, no feature flag)
which calls agentManager.collectIdleAgents({ cutoff, protectedAgentIds }) with
cutoff = Date.now() - IDLE_AGENT_RUNTIME_TTL_MS. The constant is consumed inline at the
use site, so there is no indirection a config value could hook into.

dist/server/server/agent/agent-manager.js:845 — the collectability gate:

isIdleAgentCollectable(agent, options) {
    return (agent.lifecycle === "idle" &&
        agent.updatedAt.getTime() <= options.cutoff.getTime() &&
        !agent.internal &&
        !options.protectedAgentIds.has(agent.id) &&
        agent.activeForegroundTurnId === null &&
        !this.runs.hasRun(agent.id) &&
        !agent.pendingReplacement &&
        agent.pendingPermissions.size === 0 &&
        agent.inFlightPermissionResponses.size === 0);
}

Every clause is about paseo-side state. An agent that has finished its foreground turn but
left a background process running inside the agent CLI satisfies all of them and is
collected. collectIdleAgents then calls closeAgentcloseAgentRuntime
agent.session.close(), terminating the child process.

The only exemptions are agent.internal agents and active schedule targets
(scheduleService.listActiveAgentTargetIds()).

Neither constant nor any equivalent idle-reaping logic exists in 0.1.110 (verified by
searching that version for collectIdle|idleAgent|reapIdle|closeIdle|evictIdle|idleTtl|IDLE_.*TTL|staleAgent
— the sole hit was an unrelated comment about HTTP closeIdleConnections()).

0.2.1 is not implicated: relative to 0.2.0 it changed only
agent/providers/claude/model-manifest.js and the web UI bundle. The @getpaseo/cli
package is byte-identical across 0.2.0/0.2.1/0.2.2.

Evidence

Daemon log (~/.paseo/daemon.log), one session collected 4 times in ~25 minutes:

07-26 06:25:10  provider=claude  session=cc8a43f8-…  "Collected idle agent runtime"
07-26 06:39:23  provider=claude  session=cc8a43f8-…
07-26 06:46:53  provider=claude  session=cc8a43f8-…
07-26 06:50:53  provider=claude  session=cc8a43f8-…

23 collections total in a single log file.

The 06:25:10 entry lands exactly 2 minutes after that agent's last activity (06:22:57), at
which point it was watching a CI run via Monitor. The watch never reported again and the
agent sat waiting on a dead process until the user intervened.

Kill-and-respawn confirmed at the OS level — two different PIDs serving one session id:

PID 736048  started 06:39:58  claude … --resume=cc8a43f8-…   → gone
PID 748012  started 06:59:10  claude … --resume=cc8a43f8-…   → current

Secondary impact: prompt-cache cost

Each respawn re-establishes the prompt prefix. Measured from the session transcript's
usage fields, one restart moved ~73k tokens from cache-read to cache-write:

06:22:57   cache_creation=  1,119   cache_read= 91,105
06:39:58   ← runtime respawned
06:40:12   cache_creation= 72,858   cache_read= 19,897

Cache writes bill at 2× base input on a 1-hour TTL versus 0.1× for reads, so that single
restart cost roughly 138k additional input-token equivalents (~$0.69 at Claude Opus 5
input pricing) against a session total of about $3.40 — near 20% overhead from one
collection. With a 2-minute TTL and normal think-time between messages, this repeats
throughout a working session.

cache_read dropping to 19,897 rather than 0 indicates the prefix diverges partway in
rather than being wholly invalidated. Cache TTL expiry is ruled out independently: the gap
was 17 minutes against a 1-hour cache TTL.

Suggested fixes

Roughly in order of value:

  1. Make the collectability test aware of agent-CLI background work. The provider layer
    knows whether background tasks/monitors are outstanding; surface that as a
    hasBackgroundWork() signal and add it to isIdleAgentCollectable. This is the actual
    fix — the rest are mitigations.
  2. Make the TTL configurable, with an opt-out. A daemon.idleAgentRuntimeTtlMs config
    key (0/null to disable) would let users on long-running agentic workloads turn this
    off. 2 minutes is aggressive for agents that routinely wait on CI, builds, or deploys.
  3. Tell the agent on resume. If a runtime is collected with background work
    outstanding, inject a notice on the next turn — something explicit enough that the agent
    re-checks external state instead of waiting on a dead watch. The current failure is
    silent, which is what makes it costly.
  4. Consider the cache cost in the TTL default. Collection trades a process for a
    prompt-cache rebuild on the next message. At a 2-minute TTL that trade is frequently
    unfavourable; a longer default (or an idle-cost heuristic) would help.

Notes

  • I could not determine the issue tracker from package metadata — @getpaseo/cli has no
    repository, homepage, or bugs field. Happy to move this wherever appropriate.
  • Line numbers are from the published @getpaseo/server@0.2.2 build artifacts.

Steps to reproduce

Reproduction

  1. Start a Claude Code agent in a paseo workspace.
  2. Have it launch background work that outlives the turn — e.g. a Monitor, or a
    Bash call with run_in_background: true that polls an external service.
  3. Let the agent finish its foreground turn and go idle.
  4. Wait ~2 minutes.

Observed: the daemon logs Collected idle agent runtime, the OS process is killed, and
the background work dies. The agent is not notified in any actionable way; on the next
message it resumes with no knowledge that its background task was terminated. In the UI the
context indicator disappears.

Expected: an agent with in-flight background work is not collected — or, if collection
is unavoidable, the agent is told unambiguously on resume that its background tasks were
terminated.

Where did this happen

Daemon

Paseo version

0.2.2

OS version

ubuntu 2604

Agent provider

Claude Code

Provider configuration

No response

Logs


Screenshots or video

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugReproducible unintended behavior

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions