fix(cli): stop resume from spawning a second process for a live session - #1715
Open
chphch wants to merge 1 commit into
Open
fix(cli): stop resume from spawning a second process for a live session#1715chphch wants to merge 1 commit into
chphch wants to merge 1 commit into
Conversation
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
force-pushed
the
fix/daemon-resume-duplicate-spawn
branch
from
August 25, 2026 08:00
b5f2fe7 to
23958e2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
resumeSessionnever 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,
resumeSessionlooks 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:activecarries 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 isvolatile, so a blip merely stops refreshinglastActiveAtand 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.tsas plain functions so it is unit-testable outsidestartDaemon's closure;run.tssupplies the probes.Where it came from
Seen on a daemon with two clients connected: two
resume-happy-sessionRPCs 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 ahappy-clidaemon from the branch under test, spawns one session, drives one turn so the session has acquired its agent session id, then fires the sameresume-happy-sessionRPC the app fires — twice, as two clients with different views would.Before (
7049dcd, this PR's parent) — one session ends up with three processes:After — the same two resumes return the running session:
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:
Plus 16 new unit tests over the liveness and classification helpers, and the full
happy-clisuite: 86 files, 817 tests,tsc --noEmitclean.