Skip to content

Retire stale Claude headless agents when a terminal takes over the session (concurrent-session correctness) #71

Description

@amirfish1

Problem

A Claude session can have a persistent headless agent (claude -p + stdin FIFO, CCC-spawned) AND an interactive terminal (claude --resume) live at once. A live claude process runs off in-memory state loaded at resume and never re-reads the transcript from disk (verified empirically). So:

  • The two processes silently diverge — each replies from its own memory, blind to the other's turns (all of which still land, append-only, in the shared .jsonl).
  • CCC routes injection to the terminal while a TTY exists, but the moment the terminal closes, routing falls back to the stale headless FIFO → it answers from frozen (possibly day-old) memory → the session effectively rolls back. This is the likely mechanism behind the reported "history rolled back ~a day" incident (the transcript itself is NOT corrupted — append-only holds; the live process is stale).

Background: scratch/session-states-and-control.md (states C1–C4, T1).

Scope: Claude only

Only Claude uses the persistent stdin-FIFO model (_make_stdin_fifo, O_RDWR keep-alive). Codex/Cursor/Gemini/Antigravity CLI are one-shot (fresh read of disk each turn); the Codex app-server divergence is a separate issue (T10).

Mechanisms (employ 4 + 2 + 5)

  • (4) Use-time staleness check [correctness backbone]. Before CCC writes to a headless FIFO, compare what that headless last produced against the transcript's current tail. If the .jsonl advanced via a non-headless writer → the headless is stale → retire it (_retire_unresponsive_spawn_entry) and spawn a fresh claude --resume (reads current disk) instead. Runs on the inject path, independent of any open dashboard — this is the only mechanism that catches a terminal CCC never witnessed (opened/closed between polls or while CCC was down).
  • (2) Retire on "Launch terminal". When CCC opens a terminal for a session that has a headless spawn, SIGTERM the idle headless right there (immediate, common path).
  • (5) Always-on server-side watcher (~5–10s daemon thread). Periodically: any session with a live headless + live terminal where the headless is idle → retire the headless. Cleanup so the stale process doesn't linger (~500MB) when no dashboard is open. NOTE: the existing liveStatus poll is frontend-driven (5s, only while dashboard open), so a dedicated server-side thread is needed.

Skip: (1) frontend poll (dashboard-only; (5) supersedes), (3) send+TTY (subsumed by (4)).

Hard safety rule

Never retire a BUSY headless (mid-turn / actively working) — only idle ones. Killing a working agent interrupts real work.

Acceptance

  • After a terminal drives a session, the idle headless is retired (within seconds via (5)/(2), or at next inject via (4)).
  • Closing the terminal then sending from CCC produces a response with current context (fresh resume reads disk), not a rollback.
  • A busy headless is never retired.
  • Injection path is unbroken when there's no concurrency (no regression).

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions