Files
JunZ df021fbb68 feat(daemon): idle timeout with MCP heartbeat — stop holding the embedding model in RAM forever (#215)
* feat(daemon): idle timeout — exit after inactivity, configurable via settings

The daemon now tracks client activity (every accepted connection, plus each
handler task's completion so long streaming index runs count to their end)
and exits after daemon.idle_timeout_minutes (default 180, 0 = never) without
it, instead of holding the embedding model in RAM forever. The idle exit
goes through the existing graceful shutdown path, so it leaves a last_exit
marker (reason "idle_timeout") and clients transparently and silently
restart the daemon on the next request.

- settings: new DaemonSettings dataclass with idle_timeout_minutes, parsed
  from an optional daemon: section in global_settings.yml (absent section
  and missing file both fall back to the default)
- protocol: HeartbeatRequest/HeartbeatResponse (daemon-side dispatch only;
  the MCP client loop comes separately) and idle observability fields on
  DaemonStatusResponse (idle_seconds, idle_timeout_minutes,
  last_heartbeat_seconds)
- daemon: IdleReaper with a pure should_exit predicate — exit requires a
  positive timeout, no external supervisor (COCOINDEX_CODE_DAEMON_SUPERVISED),
  no live handler task, no project indexing (new ProjectRegistry.any_indexing),
  and idle time past the timeout. A periodic asyncio task (60 s interval,
  injectable for tests along with a seconds-scale timeout on run_daemon)
  triggers the shutdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe

* feat(mcp): heartbeat loop keeps the daemon warm under a live MCP session

Both MCP entry points (ccc mcp and the legacy cocoindex-code serve) now run
a background heartbeat loop alongside the initial background index. Each
heartbeat is one short-lived daemon connection that counts as activity for
the idle reaper, so the daemon never idle-exits while an MCP session is
live; when the MCP process dies (even SIGKILL) the heartbeats stop and the
daemon exits on the normal timeout.

- client.send_heartbeat(): uses the raw non-auto-starting connect path and
  returns False when no compatible daemon answers — a heartbeat must never
  start or restart a daemon, or `ccc daemon stop` mid-session would fight
  the loop
- server.run_heartbeat_loop(): interval = timeout/3 clamped to [30 s, 300 s],
  reading the timeout from global_settings.yml (default when missing); a
  0 timeout disables the loop entirely

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe

* feat(cli): show idle timeout and heartbeat age in daemon status; document idle-exit

- ccc daemon status now prints idle duration with the configured timeout
  (or "disabled" when 0) and the age of the last MCP heartbeat (or
  "never")
- README: document daemon.idle_timeout_minutes in the global settings
  reference
- CLAUDE.md: note the idle-exit + heartbeat + crash-marker behavior in the
  process model section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe

* fix(daemon): release Windows named pipe on shutdown by unblocking the accept thread

PipeListener.close() only closes the queued next-instance handle; it cannot
cancel an in-flight ConnectNamedPipe wait, so a blocked accept() kept a pipe
instance open after the daemon exited. The named pipe therefore still existed
when the in-process idle tests asserted cleanup, and the test's own
os.path.exists() probe completed the pending connection, making the accept
thread call call_soon_threadsafe on a closed loop (the
PytestUnhandledThreadExceptionWarning noise in CI).

Shutdown now sets a shutting_down flag, wakes a blocked accept() on Windows
with a dummy client connection, and joins the accept thread before returning,
so the last pipe-instance handle is closed before run_daemon exits. POSIX
behavior is unchanged (listener.close() already unblocks accept() there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(daemon): type the idle timeout as timedelta

Review feedback on #215: a bare float-with-suffix-naming carries the
unit only by convention. IdleReaper and run_daemon now take timedelta,
converting to seconds only at the comparison against monotonic
timestamps and on the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQpbhCAsYRJuwL7X83fzDu

* refactor(daemon): drop last_heartbeat tracking — heartbeats are just activity

Review feedback on #215: last_heartbeat is MCP-specific state that the
daemon has no use for — it played no part in idle reaping and existed
only so 'ccc daemon status' could echo it back. A heartbeat's real
effect is the connection itself, which the accept path already records
as activity. Remove the field from IdleReaper, the wire protocol, and
the status output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQpbhCAsYRJuwL7X83fzDu

* refactor(daemon): send the shutdown wake-up connection unconditionally

POSIX doesn't need it (listener.close() makes accept() raise), but it's
harmless there and keeps the path exercised on every platform instead of
only on Windows CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgj6XtKW6dHST75F5VtavC

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:39:30 -07:00
..