Skip to content

fix: an idle session's backend and its MCP servers are stopped, and resume on the next message - #452

Merged
saucam merged 2 commits into
mainfrom
fix/idle-release-451
Nov 1, 2026
Merged

saucam merged 2 commits into
mainfrom
fix/idle-release-451

Conversation

@saucam

@saucam saucam commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #451.

Summary

A session left idle for 30 minutes (configurable) now has its backend process, and everything that process started (MCP servers, commands still running), stopped. The next message starts it again and resumes the same conversation. Before, every session that had run a turn kept them for the daemon's whole lifetime.

What was wrong

codeoid only ever stopped a session's backend when its account changed. So the Claude CLI (or codex, pi, the Gemini CLI, Qwen) and every MCP server it launched (Jira, Confluence, Bitbucket, Teams, memory, …) stayed running for every session ever used. Memory grew with the number of sessions touched, not the number working.

Why it matters

On a 32 GB Mac, thirteen Claude processes, most idle for 12 hours to a day and a half, plus their MCP servers filled RAM and swap (58.7 of 59.4 GB) until macOS reported "out of application memory". The same pattern shows on Linux, at about 500 MB per idle session.

What changed

  • New setting session.idleReleaseMinutes: default 30, 0 keeps the old behaviour. Also CODEOID_IDLE_RELEASE_MINUTES, and in the Settings tab.
  • A session counts down while idle. Any activity resets it; a destroy cancels it.
  • When it expires, the backend is released through the same path an account change already uses, so the next message resumes the conversation (and backends that can't resume their own get the history).
  • Never released: a session with a turn running, agent background work, a pending approval, or a backend that never started. Backends that hold no process (the HTTP ones) are left alone. A session whose last turn failed is at rest and is released like an idle one.
  • The process tree is snapshotted before the teardown, and whatever survives it is killed, as terminateBackend already does. codex and the Gemini CLI are stopped with a bare SIGKILL, so without this their MCP servers and commands would outlive them. An account-change release now reaps the same way.
  • Limits longer than one timer allows (about 24.8 days) are waited out in steps, instead of firing after 1 ms.
  • Note: the first message after a release starts the CLI again, so it re-reads the project's .mcp.json and hooks (documented in docs/mcp-backends.md). An agent that has kept a dev server running in the background past the limit, without the backend reporting it, has that server stopped.

Verification

  • 10 tests: released after the limit and resumed on the same backing session; 0 keeps it; a just-idle session keeps it; HTTP backends untouched; live background work keeps it; a destroyed session's countdown never fires; a failed turn is released; a 100,000-minute limit doesn't fire; an ambient watcher doesn't hold it; a real child process left by the backend is killed.
  • One audit round in a sandbox (correctness, plus security and robustness). Its two mediums (failed turns never released; codex and Gemini CLI children orphaned) and its lows (timer overflow, a sticky deferred release, ambient tasks, a test that always passed, the docs gap) are fixed in fe4c74d.
  • Full suite (4,987 pass, 0 fail), typecheck and lint pass. The idle and session tests also pass on CI's Bun 1.3.13.
  • Not done: a long-running check on a real daemon.

🤖 Implementation context (for agents / maintainers)

Files

  • src/daemon/session.ts: #idleReleaseTimer, #armIdleRelease() / #clearIdleRelease() beside releaseIdleProvider(); armed in #setStatus on idle or error (AT_REST; cleared on any other status), re-armed in #reconcileDrainedBackground and when only ambient tasks remain, cleared in destroy(). The countdown is chained in MAX_TIMER_MS steps to a deadline. The callback re-checks the status, !#turnActive(), #hasAgentBackgroundWork() (ignores ambient), no #pendingApprovals, and provider.hasQueried, then calls releaseIdleProvider({ idle: true }), which skips (rather than defers) when busy. Both release paths reap with snapshotTree / killSurvivors, audited as session.release_backend.reaped. Only PROCESS_BACKENDS. The timer is unref'd.
  • src/config.ts: SessionSchema.idleReleaseMinutes (z.number().min(0).default(30)), the session type, and the CODEOID_IDLE_RELEASE_MINUTES env mapping (float).
  • src/daemon/settings/manifest.ts: the Settings entry.
  • src/tests/idle-release.test.ts; docs/CONFIGURATION.md; CHANGELOG.

Follow-ups

🤖 Generated with Claude Code

saucam and others added 2 commits November 1, 2026 15:16
…d, and resume on the next message (#451)

Every session that had run a turn kept its backend process (the Claude
CLI, codex, pi, the Gemini CLI, Qwen) and the MCP servers it started for
the daemon's lifetime, so memory grew with the number of sessions ever
used. On a 32 GB Mac thirteen of them, most idle for 12 hours to a day
and a half, filled RAM and swap.

- `session.idleReleaseMinutes` (default 30; `CODEOID_IDLE_RELEASE_MINUTES`,
  and in Settings): a session idle that long has its backend released
  through the existing `releaseIdleProvider` path, and the next message
  starts it again on the same conversation. 0 keeps the old behaviour.
- The countdown starts each time the session goes idle (or its background
  work drains while idle) and is cleared by any activity or a destroy. It
  never releases a session with a turn running, background work, a pending
  approval, or a backend that never started; HTTP backends hold no process
  and are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…what the backend left running (#451)

- A session whose last turn failed (status error) is at rest too, and is
  released; so is a pending account-change release.
- A limit past setTimeout's 2^31-1 ms is waited out in steps instead of
  firing after 1 ms.
- The backend's own (ambient) watchers don't hold it.
- An idle release that finds the session busy is skipped, not turned into
  a release the moment that work ends.
- Both releases snapshot the backend's process tree before the teardown and
  kill what survives it, as terminateBackend does: codex and the Gemini CLI
  are SIGKILLed and used to leave their MCP servers and commands running.
- docs: an idle release re-reads the project's .mcp.json and hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saucam
saucam merged commit f862604 into main Nov 1, 2026
5 checks passed
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.

Idle sessions keep their backend process and its MCP servers alive forever, exhausting memory

2 participants