Skip to content

fix(server): Claude threads recover when a restart interrupts the first turn - #968

Merged
rynfar merged 4 commits into
pylonfrom
fix/claude-resume-unsaved-first-turn
Oct 1, 2026
Merged

rynfar merged 4 commits into
pylonfrom
fix/claude-resume-unsaved-first-turn

Conversation

@rynfar

@rynfar rynfar commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

If the server stopped during a thread's first Claude turn, before Claude Code wrote the transcript, the thread broke for good. Pylon generates the Claude session id and persists the resume cursor before the prompt reaches Claude. That includes the turn's prompt id, so the cursor already reads turnCount: 1. On the next start Pylon resumed an id Claude never saved, Claude replied "No conversation found with session ID", and every later message failed with a generic turn/setPermissionMode failed. Self-update continuation hit the same path. This came from T3 and is still unfixed there.

Fix

  • When the check runs (ClaudeAdapter.startSessionInternal): only an observed assistant message (resumeSessionAt) proves Claude saved the session. Turn counters are written before the prompt is sent. The check runs for a cursor Pylon wrote (it has a turnCount) with a resume id and no resumeSessionAt, outside exact-history fork starts. Imported sessions, whose cursors have no turn count, are never checked.

  • How absence is proven: the session is treated as unsaved only when both of these find nothing, within 10s:

    • the SDK's getSessionMessages called without dir, which the pinned 0.3.278 SDK documents and implements as a search of every project, matching how Claude Code's resume looks a session up;
    • a scan of every <CLAUDE_CONFIG_DIR>/projects/*/<id>.jsonl (claudeTranscriptExists). Unlike the SDK, which treats unreadable directories as empty, the scan fails when a directory can't be read.

    Any failure or timeout resumes as before, so a moved workspace or an imported session keeps its context. The scan is capped at 10,000 project directories; a larger tree counts as "unknown" and resumes.

  • Turn count is only a filter: the turnCount gate keeps freshly imported cursors out of the check. A cursor the adapter has since rewritten also has a turn count, so the count is not proof of Pylon origin; the transcript scan is what keeps a saved session resuming.

  • Unsaved session: Claude starts a new session with a fresh sessionId and no resume. The recorded turn ids and count carry over, so Pylon's turn count and rollback boundaries still line up. ProviderService persists the new cursor, and ProviderSession.conversationReset (optional, in contracts) reports the reset.

  • Self-update continuation (serverRuntimeStartup): on conversationReset no continuation turn is sent, since "Continue where you left off." into an empty session has no context. The recovered incarnation is bound, and only the projection settles, with "The server restarted before this conversation was saved, so its context was lost. Send your message again to continue."

  • Directory writes after recovery (patchOwnedBinding): marker clears and failure settles no longer upsert a full, possibly stale binding.

    • Fields written: only the fields they change (status, markers, activeTurnId), through the directory's merging upsert. The ownership check runs again as a commitGuard inside the directory's mutation permit. A user turn that advanced the cursor meanwhile, or a replacement session's binding, is never overwritten.
    • Failure settlement: it stops the binding only while it still carries the pre-restart incarnation. If recovery already started a new incarnation (for example, the start persisted the fresh cursor and then the recovered projection dispatch failed), only the markers are cleared and the live session's cursor stays.
  • Error mapping: toSessionError maps "No conversation found" to ProviderAdapterSessionNotFoundError.

Verification

  • ClaudeAdapter.test.ts:
    • A cursor taken from sendTurn, followed by stop and a restart with no saved transcript, starts fresh with carried turn ids, and the next sendTurn succeeds.
    • The same cursor with the turn's uuid in history resumes.
    • New this round:
      • A transcript saved under another project resumes, and the lookup searches all projects (no dir).
      • An imported cursor resumes without a check.
      • A history check that never returns resumes after the 10s timeout.
    • The earlier cases stay: turnCount: 0 unsaved, saved history, a read that throws, and the error mapping.
  • claudeNativeHistoryFile.test.ts: the scan finds a transcript in any project, answers no when projects are missing or hold nothing, and fails when the directory listing fails.
  • serverRuntimeStartup.reconcile.test.ts: the fake startSession now writes the fresh cursor and incarnation into the directory. With conversationReset, no continuation is sent, the error settle keeps the recovered incarnation, and the fresh cursor and incarnation survive settlement with the markers cleared.
  • serverRuntimeStartup.reconcile.test.ts, new this round: the test fakes now model the directory's merge and commitGuard semantics.
    • A same-incarnation turn that advances the cursor between the clear's read and write keeps its cursor and active turn, and the markers are cleared.
    • A replacement session's binding is left untouched.
    • A recovery whose projection dispatch fails after a successful start keeps the fresh cursor, incarnation and running status.
  • ProviderSessionDirectory.test.ts: against the real directory, a commit guard can read the binding inside the mutation permit, and a guarded partial patch changes only its fields.
  • Before the latest push (head 92bb4009): those three startup tests fail, and so does the multi-orphan test, which now asserts patch-only writes.
  • After: ClaudeAdapter, serverRuntimeStartup.reconcile, serverRuntimeStartup, claudeNativeHistoryFile and ProviderSessionDirectory tests pass 258/258.
  • Test isolation: the test harness gives the scan an empty temporary config home whenever a history hook is set, so tests never read ~/.claude.
  • vp run -F t3 typecheck passes, and scoped vp lint / vp fmt are clean.

Claude Opus 5.5 in Claude Code

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 14.0 KiB — 15.1 KiB ✅
Codex Thread snapshot wire — 7.3 KiB — 7.3 KiB ✅
Codex Live turn WebSocket wire — 6.7 KiB — 7.8 KiB ✅
Codex Live turn WebSocket decoded — 58.0 KiB — 66.4 KiB ✅
Codex Live turn messages — 9 — 21 ✅
Claude Total thread wire — 14.0 KiB — 15.1 KiB ✅
Claude Thread snapshot wire — 7.3 KiB — 7.3 KiB ✅
Claude Live turn WebSocket wire — 6.7 KiB — 7.8 KiB ✅
Claude Live turn WebSocket decoded — 58.9 KiB — 66.4 KiB ✅
Claude Live turn messages — 9 — 21 ✅

Baseline: unavailable · PR result: a6d3b96 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 115.7 KiB
  • Claude decoded thread snapshot: 116.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@rynfar
rynfar force-pushed the fix/claude-resume-unsaved-first-turn branch from b15cd28 to c1b82d8 Compare October 1, 2026 14:03
@rynfar
rynfar force-pushed the fix/claude-resume-unsaved-first-turn branch from c1b82d8 to 92bb400 Compare October 1, 2026 19:02
@github-actions github-actions Bot added size:L and removed size:M labels Oct 1, 2026
@rynfar
rynfar merged commit 5e30058 into pylon Oct 1, 2026
18 checks passed
@rynfar
rynfar deleted the fix/claude-resume-unsaved-first-turn branch October 1, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant