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:
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||
|
||||
// The bug these encode: a member's chat answered "Claude Code process exited with code 1" four times in a
|
||||
// row and could never recover, because every message retried the same `--resume` id and nothing ever
|
||||
// reconsidered it. A fresh chat worked throughout, which is what identifies the resume as the difference.
|
||||
|
||||
describe('diagnoseExit', () => {
|
||||
test('a resuming turn that died before saying anything gives up on the resume', () => {
|
||||
const d = diagnoseExit({ sawOutput: false, resumeId: '49527b33', stderrTail: '' });
|
||||
expect(d.clearResume).toBe(true);
|
||||
expect(d.message).toContain('could not reopen');
|
||||
// The cost has to be stated: the next turn genuinely will not have the history.
|
||||
expect(d.message).toContain('will not remember');
|
||||
});
|
||||
|
||||
// The important negative. A turn that spoke and then broke has a working transcript, so discarding the
|
||||
// resume would turn one failed turn into a permanently amnesiac conversation.
|
||||
test('a turn that produced output first keeps its conversation', () => {
|
||||
const d = diagnoseExit({ sawOutput: true, resumeId: '49527b33', stderrTail: 'boom' });
|
||||
expect(d.clearResume).toBe(false);
|
||||
expect(d.message).toContain('conversation is safe');
|
||||
});
|
||||
|
||||
test('a NEW session that dies has no resume to blame, so nothing is cleared', () => {
|
||||
const d = diagnoseExit({ sawOutput: false, resumeId: undefined, stderrTail: '' });
|
||||
expect(d.clearResume).toBe(false);
|
||||
expect(d.message).toContain('exited unexpectedly');
|
||||
});
|
||||
|
||||
test("the child's own words are carried to the person, not just to the log", () => {
|
||||
const d = diagnoseExit({ sawOutput: false, resumeId: 'abc', stderrTail: 'No conversation found with session ID' });
|
||||
expect(d.message).toContain('No conversation found with session ID');
|
||||
});
|
||||
|
||||
// A silent exit is the case that started this: code 1, nothing on stderr. The sentence still has to read.
|
||||
test('a silent death produces a clean sentence rather than a dangling colon', () => {
|
||||
const d = diagnoseExit({ sawOutput: false, resumeId: 'abc', stderrTail: ' \n \n' });
|
||||
expect(d.message).not.toContain(':');
|
||||
expect(d.message).toContain('could not reopen this conversation.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stderrReason', () => {
|
||||
test('keeps the last lines, where the actual error is', () => {
|
||||
expect(stderrReason('one\ntwo\nthree\nfour')).toBe('two three four');
|
||||
});
|
||||
|
||||
test('is empty for whitespace, so callers can test it as a condition', () => {
|
||||
expect(stderrReason('')).toBe('');
|
||||
expect(stderrReason(' \n \t ')).toBe('');
|
||||
});
|
||||
|
||||
test('stays short enough to sit inside a sentence', () => {
|
||||
expect(stderrReason('x'.repeat(5000)).length).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user