Skip to content

Commit dd627d5

Browse files
gaoyu06claude
andcommitted
fix(claude): actually stop the resume crash-loop ("No conversation found")
The earlier restarts<=1 guard never triggered: the bootstrap 'ready' ack fires before the --resume failure, so it reset the restart budget every cycle → the loop ran forever (276 restarts in the wild). Now the adapter detects "No conversation found with session ID" on stderr and emits a resume_failed event; ChatState sets a one-shot resumeBroken flag, and the next restart comes up fresh (no --resume) instead of re-resuming the same doomed id. The fresh session gets a new id that CAN be resumed on a later crash. The desktop transcript is preserved throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 695b833 commit dd627d5

5 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/lib/backends/claude.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,5 +1143,9 @@ describe('claude adapter: restarts and robustness', () => {
11431143
// Tracing-formatted engine log lines are dropped as noise.
11441144
expect(adapter.translate({ __stderr: '2026-07-14T10:00:00.000Z DEBUG hitting cache' })).toEqual([]);
11451145
expect(adapter.translate({ __stderr: '2026-07-14T10:00:00Z ERROR boom' })).toEqual([]);
1146+
// A failed --resume signals resume_failed (breaks the crash-restart loop).
1147+
expect(
1148+
adapter.translate({ __stderr: 'No conversation found with session ID: e4f0405e-3919-4c10' })
1149+
).toEqual([{ type: 'resume_failed' }]);
11461150
});
11471151
});

src/lib/backends/claude.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,11 @@ export function createClaudeAdapter(): EngineAdapter {
938938
const line = raw.__stderr.replace(/\[[0-9;]*m/g, '').trim();
939939
if (!line) return [];
940940
if (/^\d{4}-\d{2}-\d{2}T\S+\s+(ERROR|WARN|INFO|DEBUG|TRACE)\b/.test(line)) return [];
941+
// A --resume target the CLI can't find (session file gone / never
942+
// persisted) makes it exit immediately. Signal it so the store stops
943+
// re-resuming the same doomed id in an endless crash-restart loop
944+
// (the bootstrap 'ready' otherwise keeps resetting the restart budget).
945+
if (/No conversation found with session ID/i.test(line)) return [{ type: 'resume_failed' }];
941946
return [{ type: 'info', message: `[claude] ${line}` }];
942947
}
943948
const msg = rec(raw);

src/lib/chat.svelte.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ export class ChatState {
167167
// Engine crash auto-restart bookkeeping (driven by the page on agent-exit).
168168
restarts = 0;
169169
restartWindowStart: number | null = null;
170+
// Set when a claude --resume target isn't found: the next restart must NOT
171+
// resume the same doomed id (it would crash-loop). One-shot — consumed by the
172+
// store's restartSession, which then comes up fresh.
173+
resumeBroken = false;
170174
// Set while an intentional provider-switch restart is in flight, so the exit it
171175
// causes isn't treated as a crash to auto-restart.
172176
switching = false;
@@ -593,6 +597,11 @@ export class ChatState {
593597
case 'retrying':
594598
this.messages.push({ kind: 'system', text: `reconnecting… (attempt ${num(ev.attempt)})` });
595599
break;
600+
case 'resume_failed':
601+
// The engine couldn't resume the session id — restart fresh instead of
602+
// looping on the same doomed --resume.
603+
this.resumeBroken = true;
604+
break;
596605
case 'compaction_progress':
597606
this.compactionTokens = num(ev.output_tokens);
598607
break;

src/lib/session.svelte.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,13 @@ export class SessionStore {
217217
// claude resumes via the --resume spawn option (no /resume command in
218218
// stream-json mode); codex resumes via the thread/resume RPC (thread id
219219
// through SessionCtx); jucode resumes with the command after the handshake.
220-
// A resume target that the engine can't find ("No conversation found …")
221-
// makes it exit immediately — which would crash-loop forever re-resuming the
222-
// same doomed id. So only resume on the first restart; if that also crashes,
223-
// come back up fresh (the desktop transcript is preserved regardless).
224-
const mayResume = sid && canResume && s.chat.restarts <= 1;
220+
// A resume target the engine can't find ("No conversation found …") makes it
221+
// exit immediately, which would crash-loop forever re-resuming the same
222+
// doomed id (the bootstrap 'ready' keeps resetting the restart budget). When
223+
// the adapter flagged a resume failure, come up fresh instead. One-shot: the
224+
// fresh session gets a new id that CAN be resumed on a later crash.
225+
const mayResume = sid && canResume && !s.chat.resumeBroken;
226+
s.chat.resumeBroken = false;
225227
const resumeViaSpawn = s.backendId === 'claude' && mayResume;
226228
const resumeViaCtx = s.backendId === 'codex' && mayResume;
227229
this.#spawn(

src/lib/session.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ describe('SessionStore lifecycle', () => {
7676
expect(snap[0].tabs).toEqual([{ sid: 'sid-0', title: 'kept', archived: true }]);
7777
});
7878

79+
it('a flagged resume failure makes the next claude restart come up fresh', () => {
80+
const store = new SessionStore();
81+
const p = proj();
82+
store.projects.push(p);
83+
const id = store.addSession(p, undefined, 'claude');
84+
const s = p.sessions.find((x) => x.id === id)!;
85+
s.chat.sessionId = 'sid-x';
86+
s.chat.messages.push({ kind: 'user', text: 'hi' }); // resumable
87+
s.chat.resumeBroken = true;
88+
vi.clearAllMocks();
89+
store.restartSession(id);
90+
// Spawned without a resume option, and the one-shot flag is consumed.
91+
const call = (createSession as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(-1);
92+
expect((call?.[3] as { resume?: string } | undefined)?.resume).toBeUndefined();
93+
expect(s.chat.resumeBroken).toBe(false);
94+
});
95+
7996
it('removeProject tears down its sessions and clears a dangling activeId', () => {
8097
const store = new SessionStore();
8198
const p = proj();

0 commit comments

Comments
 (0)