Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/daemon/providers/qwen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -148,6 +152,8 @@ export class QwenProvider implements SessionProvider {
this.#hasQueried = false;
this.#pendingTools = [];
this.#seenSubagents.clear();
this.#backingRecoveryAttempted = false;
this.#lastPushedContent = null;
}

// ── AgentProvider ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -296,6 +302,7 @@ export class QwenProvider implements SessionProvider {
return;
}
try {
this.#lastPushedContent = content;
this.#inputQueue.push({
type: "user",
message: { role: "user", content },
Expand Down Expand Up @@ -459,6 +466,8 @@ export class QwenProvider implements SessionProvider {
const queue$ = this.#inputQueue;
let selfTask: Promise<void> | null = null;

let recoverContent: string | null = null;

selfTask = this.#consumerTask = (async () => {
try {
for await (const msg of query$) {
Expand All @@ -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) {
Expand All @@ -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);
}
})();
}

Expand Down Expand Up @@ -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;

Expand Down
24 changes: 24 additions & 0 deletions src/tests/provider-qwen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
normalizeModelCatalog,
fetchOpenAiModelCatalog,
unionCatalogs,
isBackingSessionMissing,
extractToolResultText,
coerceBackingId,
type QwenTranslateState,
Expand Down Expand Up @@ -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) => {
Expand Down
Loading