Skip to content

fix: recover a qwen session whose backing chat does not exist - #314

Merged
saucam merged 1 commit into
mainfrom
fix/qwen-resume-recovery
Sep 1, 2026
Merged

fix: recover a qwen session whose backing chat does not exist#314
saucam merged 1 commit into
mainfrom
fix/qwen-resume-recovery

Conversation

@saucam

@saucam saucam commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Symptom

A forked qwen session (sandbox-exelixis (fork)) failed on every message with:

Error: CLI process exited with code 1

Credentials and config were fine — a fresh qwen session in the same worktree, with the same env, model and authType, runs to result success.

Root cause

Session resume, not config.

  1. primeFromFork copies the parent's transcript into the fork's own transcript file, and deliberately does not mark the backend as started — session.ts:2516 says why: "a fork's backend is brand new and must run its first turn as a create, not a resume."
  2. On the next daemon restart, restoreScrollback (session.ts:2579) loads those copied rows, sees messages.length > 0, and calls setHasQueried(true).
  3. The fork's first real turn issues resume: <id> for a chat qwen-code has never created.

Reproduced on the affected box against @qwen-code/sdk 0.1.8 — identical env, cwd, model and authType, flipping only the session option:

Spawn option Result
sessionId: <fresh uuid> MSG: result success
resume: <unknown id> CLI process exited with code 1 (byte-identical to the daemon log)

The project's chats/ directory held no file for that id.

Why qwen-only: Claude Code tolerates resuming an unknown id and starts fresh; qwen-code exits during initialization. ClaudeProvider also actively recovers — it matches "No conversation found with session ID" and fires onRecoveryNeeded, which resets to a fresh backing id and replays the turn. QwenProvider declared that field but never fired it.

Change

Fire it, mirroring the claude path.

qwen-code gives no distinguishing message — just the generic exit — so the predicate is necessarily broader than Claude's, and will also catch a CLI that died at startup for an unrelated reason (a rejected key, say). The guards bound that cost: recovery runs only when resuming (hasQueried), only once per backing session (backingRecoveryAttempted), and re-runs the same turn as a create. A misfire costs one retry, after which the real error surfaces normally instead of being swallowed. The trade-off is documented at isBackingSessionMissing.

What this deliberately does NOT change

restoreScrollback's hasQueried heuristic is the underlying inaccuracy — "has scrollback" is not the same as "has a backend session". Gating it on a persisted backing id is not currently possible: claude_code_session_id is written only on provider switch, rotation and recovery — never on a normal first turn — so every existing session would read as "never started" and lose its backend context once. That deserves its own change with a migration, so this PR fixes the crash without taking that risk.

Operators hitting this today can unblock a stuck session with /rotate, which mints a fresh backing id and clears the flag.

Verification

  • Repro + fix confirmed against the live SDK on the affected host
  • bun test — 2414 pass, 19 skip, 0 fail (161 files)
  • bun run typecheck — clean
  • bun run lint — clean

New coverage on isBackingSessionMissing: the generic qwen exit, the wrapped initialization-error form, the claude-style message, and non-matches (code 2, connection errors, invalid_api_key, empty).

🤖 Generated with Claude Code

A forked qwen session died on its first message with "CLI process exited
with code 1", every time.

The fork is the trigger. primeFromFork copies the parent's transcript into
the fork's own transcript file and deliberately does NOT mark the backend
as started — its comment says "a fork's backend is brand new and must run
its first turn as a create, not a resume". But on the next daemon restart
restoreScrollback loads those copied rows, sees a non-empty scrollback, and
calls setHasQueried(true). The fork's first real turn then issues
`resume: <id>` for a chat qwen-code has never created.

Reproduced on the affected box against @qwen-code/sdk 0.1.8: identical env,
cwd, model and authType, flipping ONLY `sessionId:` to `resume:` for an
unknown id, turns a clean `result success` into exactly the reported error.
The project's chats/ directory held no file for that id.

This is qwen-specific because Claude Code tolerates resuming an unknown id
and starts fresh, while qwen-code exits during initialization. ClaudeProvider
additionally recovers from it — matching "No conversation found with session
ID" and firing onRecoveryNeeded, which resets to a fresh backing id and
replays the turn. QwenProvider declared that field but never fired it.

Fire it, mirroring the claude path.

qwen-code gives no distinguishing message — just the generic exit — so the
predicate is necessarily broader than Claude's and will also catch a CLI
that died at startup for an unrelated reason. The guards bound the cost:
recovery runs only when resuming (hasQueried), only once per backing session
(backingRecoveryAttempted), and re-runs the same turn as a create. A misfire
costs one retry, after which the real error surfaces normally instead of
being swallowed.

Note this does not change restoreScrollback's hasQueried heuristic, which is
the underlying inaccuracy. Gating that on a persisted backing id is not
currently possible: claude_code_session_id is written only on provider
switch, rotation and recovery — never on a normal first turn — so every
existing session would read as "never started" and lose its backend context
once. That deserves its own change with a migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@akashjavelin akashjavelin self-assigned this Sep 1, 2026
@saucam
saucam merged commit 2adeb77 into main Sep 1, 2026
4 checks 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.

2 participants