Skip to content

fix(cli): stop resume from spawning a second process for a live session - #1715

Open
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/daemon-resume-duplicate-spawn
Open

fix(cli): stop resume from spawning a second process for a live session#1715
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/daemon-resume-duplicate-spawn

Conversation

@chphch

@chphch chphch commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Resuming a Happy session that still has a running process starts a second one for the same session id, and both then serve it. A session has exactly one runtime — its message stream, metadata version and agent-state version are counters that one process advances — so two owners means the server has two writers on all three, and the user's next message is delivered to both and acted on by both. Nothing downstream de-duplicates it. resumeSession never checked whether the session already had a process, and the request is not necessarily a mistake: the caller decides from its own view, and a second device (or a client that has not yet received the reconnect) shows the session as stopped for as long as its state is behind. This adds the check on the daemon side, where the answer is knowable, and distinguishes the two states that can produce the request — a healthy owner, whose caller is simply stale, from one that is up but no longer serving, which is what the user is actually trying to escape.

What it does

Before spawning, resumeSession looks for a process that still owns the session: in the live tracking map, and — since sessions are detached and outlive the daemon that started them — in the on-disk record, whose PID is trusted only when it was written during the current boot (a reboot resets the PID space, so an older PID names whatever reused the number). If one is found, it asks the server whether a runtime is still attached:

  • attached → return the running session instead of spawning. The caller's view was stale.
  • not attached → the process is holding a dead socket. Stop it, confirm it is gone, then spawn the replacement. If it will not die, report that rather than spawning alongside it.
  • server unanswerable → treat as attached. The two mistakes are not symmetric: refusing to spawn costs the user a retry, while killing on a guess costs whatever the process was in the middle of doing.

active carries that distinction reliably because of how it is written: a running session heartbeats every 2s (claude/session.ts), and the flag only clears on an explicit shutdown signal (session-end, or the deactivate route the CLI calls on SIGTERM) or after 10 minutes of silence (presence/timeout.ts). A dropped connection does not flip it — the heartbeat is volatile, so a blip merely stops refreshing lastActiveAt and the 10-minute sweep is the grace period.

Concurrent resume requests for one session now share a single attempt, since the check reads state that the spawn it guards is about to change and a new process takes a second or two to report itself.

The decision itself lives in daemon/sessionLiveness.ts as plain functions so it is unit-testable outside startDaemon's closure; run.ts supplies the probes.

Where it came from

Seen on a daemon with two clients connected: two resume-happy-session RPCs 24s apart, two live processes on one session id, and every subsequent message handled twice by a session that was in the middle of a task. Across five days of daemon logs on that machine it happened once — rare, but the damage is a session doing the same work twice with no way to tell.

Proof

The observable is a process count, not a screen, so this is a before/after against the same harness rather than a GIF. Each run boots an isolated happy-server (PGlite) plus a happy-cli daemon from the branch under test, spawns one session, drives one turn so the session has acquired its agent session id, then fires the same resume-happy-session RPC the app fires — twice, as two clients with different views would.

Before (7049dcd, this PR's parent) — one session ends up with three processes:

== owners before resume: 1 [17633]
[22:23:01.703] [DAEMON RUN] Spawned process with PID 17979
[22:23:02.302] [DAEMON RUN] Session webhook: cmt79nbjq…, PID: 17979
[22:23:10.634] [DAEMON RUN] Spawned process with PID 18112
[22:23:10.999] [DAEMON RUN] Session webhook: cmt79nbjq…, PID: 18112
== owners after 2 resumes: 3 [17633,17979,18112]

After — the same two resumes return the running session:

== owners before resume: 1 [18888]
[22:24:29.143] [DAEMON RUN] Session cmt79p9y4… is still owned by live PID 18888
    (server presence: active=true) — returning the running session instead of
    spawning a second process
[22:24:37.515] … same for the second request
== owners after 2 resumes: 1 [18888]

The replace branch, separately — a live process the server no longer sees is exchanged, not left in place, so a genuinely wedged session is still recoverable. Reproduced by marking the session inactive server-side while its process keeps running:

== owners before: 1 [21456]
== marked inactive server-side: 200
== owner still running after that: 1 [21456]
[22:25:55.526] [DAEMON RUN] Session cmt79r5a2… is owned by PID 21456 but the
    server reports no runtime attached — replacing it
[22:25:55.527] [DAEMON RUN] Stopping unresponsive owner PID 21456
[22:25:55.634] [DAEMON RUN] Spawned process with PID 23113
== owners after: 1 [23113]   (replaced=true)

Plus 16 new unit tests over the liveness and classification helpers, and the full happy-cli suite: 86 files, 817 tests, tsc --noEmit clean.

A Happy session has exactly one runtime. Its message stream, metadata
version and agent-state version are counters that one process advances,
so a second process spawned for the same session gives the server two
writers on all three — the user's next message is delivered to both, and
both act on it. Nothing downstream de-duplicates that.

`resumeSession` never checked whether the session it was asked to resume
still had a process, so any resume request that arrived while one was
running produced exactly that. The request is not necessarily a mistake:
the caller decides from its own view, and a second device (or a client
that has not yet received the reconnect) shows the session as stopped for
as long as its state is behind. Observed on a daemon with two clients
connected — two resume RPCs 24s apart, two live processes on one session
id, every subsequent message handled twice.

Before spawning, look for a process that still owns the session, in the
live tracking map and — since sessions are detached and outlive the
daemon that started them — in the on-disk record, whose PID is only
trusted when it was written during the current boot. When one is found,
ask the server whether a runtime is still attached, which separates the
two states that can produce the request: a healthy owner means the caller
is stale, so return the running session; an owner the server has stopped
seeing is holding a dead socket, which is what the user is trying to
escape, so stop it and confirm it is gone before replacing it.

`active` carries that distinction reliably because of how it is written:
a running session heartbeats every 2s, and the flag only clears on an
explicit shutdown signal or after 10 minutes of silence, so a dropped
connection does not flip it. When the server cannot be asked at all, the
answer is "still running" — refusing to spawn costs a retry, whereas
killing on a guess costs whatever the process was in the middle of doing.

Concurrent requests for one session now share a single attempt, since the
check reads state that the spawn it guards is about to change and a new
process takes a second or two to report itself.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@chphch
chphch force-pushed the fix/daemon-resume-duplicate-spawn branch from b5f2fe7 to 23958e2 Compare August 25, 2026 08:00
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