From bb1e128d32c928396d13837d3e4d7b8a876b54f3 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 03:43:16 +0200 Subject: [PATCH] Runs: an expired Claude sign-in asks to sign in again Claude Code now ends a run whose sign-in ran out with "Failed to authenticate: OAuth session expired and could not be refreshed". The sign-in check only knew "OAuth token has expired", so the chat offered a plain retry that fails the same way. Both the runner and the chat's turn end now read it as a sign-in problem; a 403 refusal stays a retry. --- packages/core/src/runner/runner.ts | 4 ++-- packages/core/test/fixtures/fake-claude.ts | 7 +++++++ packages/core/test/frame.test.ts | 2 ++ packages/core/test/runner.test.ts | 9 +++++++++ packages/shared/src/retry.ts | 2 +- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index ab081b7f..b2109df2 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -187,7 +187,7 @@ const PAUSE_GRACE_MS = 8000; /** Continuations a Stop hook (`/goal`) forces in a row without a tool call before Godmode ends the run. */ const GOAL_IDLE_LIMIT = 2; const SESSION_MISSING = /no conversation found|session(?: id)? [^\n]{0,80}not found|could not find session|no such session/i; -const AUTH_PROBLEM = /not logged in|please run \/login|invalid api key|authentication_error|oauth token (?:has )?expired|credit balance is too low/i; +const AUTH_PROBLEM = /not logged in|please run \/login|invalid api key|authentication_error|oauth (?:token|session) (?:has )?expired|credit balance is too low/i; /** Built-in Claude Code tools of a dream run: reading and editing the memory files, nothing else. */ const DREAM_TOOLS = "Read,Write,Edit,Glob,Grep"; /** @@ -2786,7 +2786,7 @@ async function finalize(job: Job, outcome: Outcome, agent: Agent | null, started if (!wasRunning || !finished || !agent) return; // A dream's summary may talk about past login trouble — that is no new missing login. Neither is Claude Code's own // error ("Failed to authenticate. API Error: 403") standing in for the answer. - const claudeError = outcome.status === "failed" && (text.trim() === error?.trim() || /^(?:Failed to authenticate\.|API Error:)/.test(text.trim())); + const claudeError = outcome.status === "failed" && (text.trim() === error?.trim() || /^(?:Failed to authenticate[.:]|API Error:)/.test(text.trim())); if (outcome.status !== "cancelled" && job.trigger !== "dream" && !claudeError) await detectMissingLogin(job, agent, text); // Condition checks change nothing worth a commit; the task run that follows commits as usual. Dreams commit their diff --git a/packages/core/test/fixtures/fake-claude.ts b/packages/core/test/fixtures/fake-claude.ts index 2e118e9b..404fa59a 100755 --- a/packages/core/test/fixtures/fake-claude.ts +++ b/packages/core/test/fixtures/fake-claude.ts @@ -10,6 +10,7 @@ * SLEEP emit init, then hang (cancel / timeout tests) * LOGIN_FAIL answer with a login-failure sentence * AUTH_403 fail like Claude Code when Claude's API refuses the sign-in ("Failed to authenticate. API Error: 403 …") + * AUTH_EXPIRED fail like Claude Code when its sign-in ran out ("Failed to authenticate: OAuth session expired …") * CALL_MCP call the Godmode MCP gateway from --mcp-config (initialize, tools/list, report_missing_login) * CALL_COMPUTER call the `computer` MCP server from --mcp-config (initialize, tools/list, computer_info) and answer * with a JSON summary; "no computer server" when the run has none @@ -854,6 +855,12 @@ if (slash?.[1] === "clear") { out({ type: "assistant", message: { id: crypto.randomUUID(), model: "", role: "assistant", content: [{ type: "text", text }] }, parent_tool_use_id: null, session_id: sessionId }); out({ type: "result", subtype: "success", is_error: true, api_error_status: 403, result: text, session_id: sessionId, total_cost_usd: 0, duration_ms: 7, num_turns: 1 }); process.exit(1); +} else if (prompt.includes("AUTH_EXPIRED")) { + out(init); + const text = "Failed to authenticate: OAuth session expired and could not be refreshed"; + out({ type: "assistant", message: { id: crypto.randomUUID(), model: "", role: "assistant", content: [{ type: "text", text }] }, parent_tool_use_id: null, session_id: sessionId }); + out({ type: "result", subtype: "success", is_error: true, api_error_status: 401, result: text, session_id: sessionId, total_cost_usd: 0, duration_ms: 7, num_turns: 1 }); + process.exit(1); } else if (prompt.includes("LOGIN_FAIL")) { out(init); const text = "I opened the dashboard. I couldn't log in to example.com because there are no saved credentials for it."; diff --git a/packages/core/test/frame.test.ts b/packages/core/test/frame.test.ts index c8e5e1b1..2537b482 100644 --- a/packages/core/test/frame.test.ts +++ b/packages/core/test/frame.test.ts @@ -60,6 +60,8 @@ describe("a turn that ended early", () => { expect(runEndOf("Timed out after 60 minutes")).toEqual({ kind: "timeout", minutes: 60 }); expect(runEndOf("Cancelled by user")).toEqual({ kind: "stopped", byUser: true }); expect(runEndOf("Claude Code is not signed in (or the API key is invalid).")?.kind).toBe("auth"); + expect(runEndOf("Failed to authenticate: OAuth session expired and could not be refreshed")?.kind).toBe("auth"); + expect(runEndOf("Failed to authenticate. API Error: 403 Request not allowed")).toBeNull(); expect(runEndOf("Prompt is too long")?.kind).toBe("context"); expect(runEndOf("This work is set to run in a virtual machine, but virtual machines are turned off (Settings → Virtual machines).")).toEqual({ kind: "vm", off: true }); expect(runEndOf("The virtual machine can't be used: boot timed out")).toEqual({ kind: "vm" }); diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index 264e5fac..b10c91ef 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -334,6 +334,15 @@ describe("runner end-to-end with fake claude", () => { expect(listMissingLogins({}).filter((m) => m.runId === run.id)).toHaveLength(0); }); + test("an expired Claude sign-in asks to sign in again", async () => { + const { run } = await startChat({ agentId: agent.id, content: "AUTH_EXPIRED" }); + const finished = await waitForRun(run.id, 20_000); + expect(finished.status).toBe("failed"); + expect(finished.error).toStartWith("Claude Code is not signed in"); + await new Promise((r) => setTimeout(r, 200)); + expect(listMissingLogins({}).filter((m) => m.runId === run.id)).toHaveLength(0); + }); + test("runs reach the MCP gateway; an explicit report disables the heuristic", async () => { const { run } = await startChat({ agentId: agent.id, content: "CALL_MCP" }); const finished = await waitForRun(run.id, 20_000); diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 8fdc11d9..7192bdbe 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -30,7 +30,7 @@ export function runEndOf(text: string): RunEnd | null { if (t.startsWith(RUN_WATCHDOG)) return { kind: "stalled" }; if (/prompt is too long|context (?:length|window)|conversation is too long/i.test(t)) return { kind: "context" }; if (t.startsWith(RUN_COST_LIMIT)) return { kind: "budget" }; - if (/^Claude Code is not signed in/.test(t) || /^(?:invalid api key|authentication_error|oauth token (?:has )?expired|credit balance is too low)/i.test(t)) return { kind: "auth" }; + if (/^Claude Code is not signed in/.test(t) || /^(?:failed to authenticate: )?(?:invalid api key|authentication_error|oauth (?:token|session) (?:has )?expired|credit balance is too low)/i.test(t)) return { kind: "auth" }; if (t.startsWith(RUN_CLI_MISSING)) return { kind: "cli" }; if (/Pick another folder for this chat\.|Change the default folder in /.test(t)) return { kind: "folder" }; if (/virtual machines are turned off/i.test(t)) return { kind: "vm", off: true };