Skip to content

Let the daemon own an unattended relaunch; the pane waits for it - #312

Merged
scgopi merged 2 commits into
mainfrom
fix/restart-resumes
Sep 7, 2026
Merged

scgopi merged 2 commits into
mainfrom
fix/restart-resumes

Conversation

@scgopi

@scgopi scgopi commented Sep 7, 2026

Copy link
Copy Markdown
Owner

The report

Restarting a loop started a fresh session instead of resuming it — a goal-based loop re-issued its /goal directive and the conversation the restart existed to keep was orphaned. Reported against both app affordances (Loop ▸ Restart Session, Restart All Sessions…), reproducible on the second press.

What was measured

The restart machinery is not at fault. Every path I could drive resumed correctly:

Path Backend Type Result
graphcode node restart Claude Code goal ensure resume
graphcode node restart Claude Code time ensure resume
graphcode node restart Copilot goal --resume, same directory, twice
app Restart Session Claude Code turn open resume
app Restart Session OpenCode time ensure resume

The failing case, a Copilot goal loop open in the app, logged this — twice, over two presses:

00:54:16 restart killed
00:54:16 ensure resume    ← the daemon did the right thing
00:54:16 open fresh       ← the pane launched fresh, same second

and the surviving process was the pane's: copilot --yolo --name graphcode-… --interactive "$GRAPHCODE_TRIGGER_PROMPT" — no --resume, goal re-issued. The controlled comparison is the point: same backend, same loop type, no pane open → resumed on both restarts.

Root cause

GhosttyTerminalView.localResumeOrFreshCommand guards on SessionIDStore.load(forNodeID:) != nil. Its unbanked fallback had no zmx get guard and no resume branch — it unconditionally created the session fresh. That is harmless while a session is alive, because zmx attach ignores its command argument then, and fatal in the window right after a restart's kill, where it races ZmxSessionLauncher.restart's detached start and wins often enough to be the bug.

Local Copilot took that branch every time: nothing banks a Copilot session id on this machine — CopilotSessionLog's banker is the remote ensure's, and Copilot has no SessionStart hook the way Claude Code does — so SessionIDStore.load is permanently nil for it locally and the resume branch was unreachable. Claude Code, OpenCode and Codex all bank, which is why they resumed and Copilot did not.

The Copilot half is a regression from d8ea180, which reverted the local Copilot resume work (#248/#249) on 2026-09-02.

The change

An unattended loop's pane now waits for the session the daemon is starting and attaches to it, instead of racing it with a launch that carries the prompt — the same ownership split the remote pane already makes for a missing unattended session, and the same one the Codex pane makes for every launch. An attended loop keeps the fresh launch: no daemon starts a turn-based loop, so a pane that waited for one would wait out its whole minute.

waitingAttachCommand gains a dial line (open await-daemon), since waiting was otherwise the one launch decision the dial log could not tell apart from a pane that never opened.

Verification

From the worktree, private DerivedData, exit codes quoted:

  • xcodebuild … -scheme graphcode buildexit=0, ** BUILD SUCCEEDED **, 0 errors
  • xcodebuild … -scheme graphcode testexit=0, ** TEST SUCCEEDED **, 1643 tests in 170 suites passed
  • xcodebuild … -scheme graphcode-cli buildexit=0 · -scheme graphcoded buildexit=0
  • swiftlint lintexit=0, 0 errors · swift format lint --strictexit=0

New/changed tests, all observed running:

  • anUnattendedNodeWithNothingBankedWaitsForTheDaemonInsteadOfRacingIt — goal and time, asserts it waits, logs open await-daemon, and never types the opening prompt
  • anAttendedNodeWithNothingBankedStillLaunchesFresh — was aNodeWithNothingBankedStillLaunchesFresh, now pinned to a turn-based loop, which is the case that branch is still for

Noted, not fixed here

A local project has no repeating liveness sweep — ProjectRegistry starts startRemoteLivenessSweep only for remote projects, so a local unattended loop whose session dies is not revived until the daemon restarts or the project reloads. Verified by raw-killing a probe's session and watching nothing bring it back. That is the already-tracked issue #276.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KdnA1YedzfZVxRsNVGuKAN

scgopi and others added 2 commits September 6, 2026 18:07
Restarting a loop from the app started a fresh session instead of resuming:
the agent came up on its opening prompt, so a goal-based loop re-issued
`/goal` and the conversation the restart existed to keep was orphaned.

The restart machinery was not at fault. `graphcoded` kills and then starts
with `--resume`, and the dial log says so — but the app remounts the pane on
the same beat, and `localResumeOrFreshCommand`'s unbanked fallback had no
`zmx get` guard and no resume branch. It unconditionally created the session
fresh, and beat the daemon's detached start often enough to be the bug.
Harmless while a session is alive, because `zmx attach` ignores its command
argument then; fatal in the window right after a restart's kill.

Local Copilot took that branch every time. Nothing banks a Copilot session id
on this machine — `CopilotSessionLog`'s banker is the remote ensure's, and
Copilot has no `SessionStart` hook the way Claude Code does — so
`SessionIDStore.load` is permanently nil for it locally and the resume branch
was unreachable. Claude Code, OpenCode and Codex all bank, so they resumed.

So an unattended loop's pane now waits for the session the daemon is starting
and attaches to it, rather than racing it with a launch that carries the
prompt. That is the same ownership split the remote pane already makes for a
missing unattended session, and the same one the Codex pane makes for every
launch. An attended loop keeps the fresh launch: no daemon starts a turn-based
loop, so a pane that waited for one would wait out its whole minute.

`waitingAttachCommand` gains a dial line, since waiting was otherwise the one
launch decision the log could not tell apart from a pane that never opened.

Measured on the reported case — a Copilot goal loop open in the app logged
`restart killed`, `ensure resume` and `open fresh` in the same second, twice
over two presses, and the surviving process was the pane's fresh launch. The
same backend and loop type with no pane open resumed on both restarts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdnA1YedzfZVxRsNVGuKAN
@scgopi
scgopi merged commit 1415571 into main Sep 7, 2026
1 check 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.

1 participant