a chat that cannot be resumed stops being permanently dead
A member's conversation answered "Claude Code process exited with code 1" to every message, four times identically, and could never recover. A brand new chat worked throughout. Diagnosing it took an evening and produced two wrong answers — the credential, then the working directory — both disproved by hand on the server, because the one input most likely to be at fault appeared in no log anywhere. Three changes, and the first is the one that matters most next time. ── Say what is being run ── `[claude:spawn:<key>] resume=… cwd=… as=… model=…`, before the spawn. The resume id is NOT the id in the URL — it comes from a stored map, so the two can disagree, and nothing could previously show that they had. Every hypothesis about it had to be tested by hand. ── Carry the child's reason to the person who hit it ── stderr was logged and then dropped, so the client got a number. The tail is now kept on the session and travels with the exit, both to the log and to the user. When the child says nothing at all — which is what happened here — the sentence still reads, rather than trailing a dangling colon. ── Stop retrying a resume that cannot work ── `sawOutput` records whether the child ever produced a single message. False, on a turn that was resuming, means it never got as far as loading the conversation — and since a fresh chat works with the same process, account, credential and binary, `--resume` is the only input that differs. So the stored id is dropped and the next message starts clean. The history is lost to the agent, which is a real cost, and strictly better than a thread that can only ever return an error. Nothing reconsidered that id before, which is why four identical failures in a row was the floor rather than a phase. Deliberately NOT done when the child spoke first: that is a turn that broke halfway, the transcript is fine, and clearing it would trade one bad turn for an amnesiac conversation. That negative case is the second test. `diagnoseExit` is its own module because everything around it needs the SDK to construct — which is how the old single sentence went unexamined for so long. 8 tests. This does NOT explain why the resume failed; it makes the next occurrence say so instead of costing an evening. The cause is still open. tsgo clean, 945 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anth
|
||||
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||
import { createParseState, processMessage } from './stream-parser';
|
||||
import { spawnClaudeAsMember } from './spawn-as-member';
|
||||
import { claudeBinIn } from '@@/os-user-claude';
|
||||
@@ -217,6 +218,12 @@ type PersistentSession = {
|
||||
* side that called `interrupt()` knows better, so it says so here.
|
||||
*/
|
||||
interrupted: boolean;
|
||||
/** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */
|
||||
resumeId?: string;
|
||||
/** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */
|
||||
stderrTail: string;
|
||||
/** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */
|
||||
sawOutput: boolean;
|
||||
/**
|
||||
* When the `PreCompact` hook fired, so the `compact_boundary` that closes it can carry how long the
|
||||
* silence lasted. The harness reports the boundary but not the duration, and the duration is the part
|
||||
@@ -300,12 +307,29 @@ function dropDeadSession(session: PersistentSession, detail: string): void {
|
||||
// A deliberate teardown (kill / idle GC) aborts first, and its exit is not news.
|
||||
if (session.abort.signal.aborted) return;
|
||||
|
||||
console.error(`[claude:exit:${session.sessionKey}] ${detail}`);
|
||||
if (!wasGenerating) return;
|
||||
session.emit({
|
||||
type: 'error',
|
||||
message: 'The agent process exited unexpectedly. Your conversation is safe — send again to continue.',
|
||||
const diagnosis = diagnoseExit({
|
||||
sawOutput: session.sawOutput,
|
||||
resumeId: session.resumeId,
|
||||
stderrTail: session.stderrTail,
|
||||
});
|
||||
const reason = stderrReason(session.stderrTail);
|
||||
|
||||
console.error(
|
||||
`[claude:exit:${session.sessionKey}] ${detail} resume=${session.resumeId ?? 'none'} ` +
|
||||
`sawOutput=${session.sawOutput}${reason ? ` stderr="${reason}"` : ' stderr=<silent>'}`,
|
||||
);
|
||||
|
||||
// Dropping the mapping is what makes the thread usable again — until this, the failing id was retried on
|
||||
// every message forever. See `diagnoseExit` for why `sawOutput` is the thing that decides it.
|
||||
if (diagnosis.clearResume) {
|
||||
clearClaudeSession(session.sessionKey);
|
||||
console.error(
|
||||
`[claude:exit:${session.sessionKey}] cleared the stored resume id — the next message starts a new session`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!wasGenerating) return;
|
||||
session.emit({ type: 'error', message: diagnosis.message });
|
||||
}
|
||||
|
||||
/** Wrap a spawn so the session self-heals when its child goes away. */
|
||||
@@ -434,6 +458,8 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
isGenerating: false,
|
||||
pendingTasks: new Set<string>(),
|
||||
interrupted: false,
|
||||
stderrTail: '',
|
||||
sawOutput: false,
|
||||
};
|
||||
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
@@ -441,6 +467,23 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
|
||||
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
|
||||
const subModel = params.model?.split('/')[1];
|
||||
session.resumeId = resumeId;
|
||||
|
||||
/**
|
||||
* What we are about to run, before we run it.
|
||||
*
|
||||
* A member's turn died on `exit code=1` with no stderr and nothing else logged, and the resume id — the
|
||||
* one input most likely to be wrong — appeared in no log anywhere. Every hypothesis about it therefore
|
||||
* had to be tested by hand on the server, which took an evening and produced two wrong answers before
|
||||
* the right question. One line here would have replaced all of it.
|
||||
*
|
||||
* The resume id in particular is not the id in the URL: it comes from the stored map, so the two can
|
||||
* disagree, and only this line can show that they have.
|
||||
*/
|
||||
console.log(
|
||||
`[claude:spawn:${sessionKey}] resume=${resumeId ?? 'none'} cwd=${params.cwd ?? 'default'} ` +
|
||||
`as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`,
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||
@@ -495,7 +538,11 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
settingSources: ['user', 'project', 'local'],
|
||||
env: cleanEnv as Record<string, string>,
|
||||
stderr: (d: string) => {
|
||||
if (d.trim()) console.error(`[claude:stream:${sessionKey}] ${d.slice(0, 300)}`);
|
||||
if (!d.trim()) return;
|
||||
console.error(`[claude:stream:${sessionKey}] ${d.slice(0, 300)}`);
|
||||
// Kept as well as logged, so the exit can carry it. A reason that only exists in the server's log
|
||||
// is a reason the person who hit it cannot see, and they are the one who has to decide what to do.
|
||||
session.stderrTail = `${session.stderrTail}${d}`.slice(-2000);
|
||||
},
|
||||
...(subModel ? { model: subModel } : {}),
|
||||
...(resumeId ? { resume: resumeId } : {}),
|
||||
@@ -577,6 +624,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
};
|
||||
try {
|
||||
for await (const msg of q as AsyncGenerator<Record<string, unknown>>) {
|
||||
// One message — any message — means the child got far enough to talk. That distinguishes a resume
|
||||
// that could not even load from a turn that broke halfway, which want opposite handling.
|
||||
session.sawOutput = true;
|
||||
processMessage(msg, state, {
|
||||
onEvent: emit,
|
||||
onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId),
|
||||
|
||||
Reference in New Issue
Block a user