From 5b8608770425beeeb86fff9fa4fcaa3dbb3aeae8 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 1 Sep 2026 16:05:20 +0800 Subject: [PATCH] fix: recover a qwen session whose backing chat does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` 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) --- src/daemon/providers/qwen/index.ts | 61 +++++++++++++++++++++++++++++- src/tests/provider-qwen.test.ts | 24 ++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts index d69ed4d..9091ab5 100644 --- a/src/daemon/providers/qwen/index.ts +++ b/src/daemon/providers/qwen/index.ts @@ -117,6 +117,10 @@ export class QwenProvider implements SessionProvider { /** Resolved gateway URL + credential path of the live loop — see #loadCatalog. */ #currentBaseUrl: string | null = null; #currentAuthType: "openai" | "qwen-oauth" | null = null; + /** Guards backing-session recovery to one attempt — see the consumer's catch. */ + #backingRecoveryAttempted = false; + /** Last content pushed to the CLI, replayed if recovery fires. */ + #lastPushedContent: string | null = null; constructor(init: QwenProviderInit) { this.#backingId = coerceBackingId(init.initialBackingId, init.sessionId); @@ -148,6 +152,8 @@ export class QwenProvider implements SessionProvider { this.#hasQueried = false; this.#pendingTools = []; this.#seenSubagents.clear(); + this.#backingRecoveryAttempted = false; + this.#lastPushedContent = null; } // ── AgentProvider ───────────────────────────────────────────────────────── @@ -296,6 +302,7 @@ export class QwenProvider implements SessionProvider { return; } try { + this.#lastPushedContent = content; this.#inputQueue.push({ type: "user", message: { role: "user", content }, @@ -459,6 +466,8 @@ export class QwenProvider implements SessionProvider { const queue$ = this.#inputQueue; let selfTask: Promise | null = null; + let recoverContent: string | null = null; + selfTask = this.#consumerTask = (async () => { try { for await (const msg of query$) { @@ -468,8 +477,20 @@ export class QwenProvider implements SessionProvider { } catch (err) { if (!ac.signal.aborted && this.#loopGeneration === myGeneration) { const emsg = err instanceof Error ? err.message : String(err); - console.error(`[qwen-provider ${init.sessionId.slice(0, 8)}] SDK query failed: ${emsg}`); - this.#emit({ type: "error", message: emsg }); + if ( + this.#hasQueried && + !this.#backingRecoveryAttempted && + this.#lastPushedContent !== null && + isBackingSessionMissing(emsg) + ) { + console.error( + `[qwen-provider ${init.sessionId.slice(0, 8)}] backing session missing — scheduling recovery`, + ); + recoverContent = this.#lastPushedContent; + } else { + console.error(`[qwen-provider ${init.sessionId.slice(0, 8)}] SDK query failed: ${emsg}`); + this.#emit({ type: "error", message: emsg }); + } } } finally { if (this.#loopGeneration === myGeneration) { @@ -482,6 +503,14 @@ export class QwenProvider implements SessionProvider { if (this.#inputQueue === queue$) this.#inputQueue = null; if (this.#consumerTask === selfTask) this.#consumerTask = null; } + + // Post-teardown recovery, mirroring ClaudeProvider: Session resets us to + // a fresh backing id and replays the turn as a create. Skipped for a + // superseded loop — the rebuild already owns session continuity. + if (recoverContent !== null && this.#loopGeneration === myGeneration) { + this.#backingRecoveryAttempted = true; + this.onRecoveryNeeded?.(recoverContent); + } })(); } @@ -845,6 +874,34 @@ export function normalizeModelCatalog(raw: unknown): ModelInfo[] { return out; } +/** + * Does this SDK error look like "the backing session I asked to resume does + * not exist"? + * + * ClaudeProvider can match a precise string ("No conversation found with + * session ID"). qwen-code gives us nothing that specific: asked to `resume` a + * chat id it has no file for, the CLI exits during initialization and the SDK + * surfaces the generic `CLI process exited with code 1`. Reproduced against + * @qwen-code/sdk 0.1.8 — flipping only `sessionId:` to `resume:` for an + * unknown id turns a clean `result success` into exactly that error. + * + * So the match is necessarily broader than Claude's and will also catch a CLI + * that died at startup for an unrelated reason (a rejected key, say). That is + * acceptable because of how the call site is guarded: recovery only runs when + * we were RESUMING (`hasQueried`), only once per backing session + * (`backingRecoveryAttempted`), and it re-runs the same turn as a create. A + * misfire therefore costs one retry, after which the real error surfaces + * normally instead of being swallowed. + * + * Exported for unit testing. + */ +export function isBackingSessionMissing(message: string): boolean { + return ( + /CLI process exited with code 1/i.test(message) || + /No conversation found with session ID/i.test(message) + ); +} + /** Give up on a catalog fetch well inside any reasonable session-start budget. */ const CATALOG_FETCH_TIMEOUT_MS = 10_000; diff --git a/src/tests/provider-qwen.test.ts b/src/tests/provider-qwen.test.ts index cf0e907..c58349d 100644 --- a/src/tests/provider-qwen.test.ts +++ b/src/tests/provider-qwen.test.ts @@ -5,6 +5,7 @@ import { normalizeModelCatalog, fetchOpenAiModelCatalog, unionCatalogs, + isBackingSessionMissing, extractToolResultText, coerceBackingId, type QwenTranslateState, @@ -427,6 +428,29 @@ describe("normalizeModelCatalog", () => { }); }); +describe("isBackingSessionMissing", () => { + // A fork primed from its parent's transcript has scrollback but no backing + // chat; after a daemon restart restoreScrollback marks it hasQueried, the + // next turn issues `resume`, and qwen-code exits 1 during initialization. + test("matches the generic CLI exit qwen-code gives for an unknown resume id", () => { + expect(isBackingSessionMissing("CLI process exited with code 1")).toBe(true); + expect( + isBackingSessionMissing("[Query] Initialization error: CLI process exited with code 1"), + ).toBe(true); + }); + + test("matches the claude-style message too", () => { + expect(isBackingSessionMissing("No conversation found with session ID: abc")).toBe(true); + }); + + test("does not match unrelated failures", () => { + expect(isBackingSessionMissing("CLI process exited with code 2")).toBe(false); + expect(isBackingSessionMissing("fetch failed: ECONNREFUSED")).toBe(false); + expect(isBackingSessionMissing("invalid_api_key")).toBe(false); + expect(isBackingSessionMissing("")).toBe(false); + }); +}); + describe("fetchOpenAiModelCatalog", () => { function stubFetch(status: number, body: unknown): typeof fetch { return (async (url: string | URL | Request, init?: RequestInit) => {