From e6c73313d802e235f52fc9504c68716cd44abfc4 Mon Sep 17 00:00:00 2001 From: Marco Mendoza Date: Fri, 31 Jul 2026 22:28:52 +0200 Subject: [PATCH] fix(server): treat signal-killed Claude subprocess as interrupted - ClaudeAdapter now recognizes "terminated by signal SIGTERM/SIGINT" from the Claude Agent SDK's ProcessTransport as a graceful interruption instead of surfacing a runtime.error - Covers the case where the OS delivers the signal directly to the subprocess (e.g. desktop app quit) ahead of our own stopSession()-driven close - Add regression test asserting no runtime.error is emitted and the turn completes with state "interrupted" --- .../src/provider/Layers/ClaudeAdapter.test.ts | 59 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 10 +++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 760f0e7fbab..c88cfd77958 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1508,6 +1508,65 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "treats an externally signal-killed subprocess as interrupted, not a runtime error", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + // Model the desktop app's quit sequence delivering SIGTERM directly + // to the Claude CLI subprocess (e.g. via OS-level process-tree + // teardown), independently of and *before* our own graceful + // stopSession() flow ever runs. The real + // `@anthropic-ai/claude-agent-sdk` produces exactly this message + // (ProcessTransport#getProcessExitError) when its child process + // exits via a signal instead of a clean stopSession()-driven close. + harness.query.fail(new Error("Claude Code process terminated by signal SIGTERM")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal( + runtimeError, + undefined, + "a SIGTERM/SIGINT-terminated subprocess should never surface as a runtime.error", + ); + + const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "interrupted"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("keeps Claude stream failure events structural", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f87d5be7446..56881566ee1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -282,7 +282,15 @@ function isClaudeInterruptedMessage(message: string): boolean { return ( normalized.includes("all fibers interrupted without error") || normalized.includes("request was aborted") || - normalized.includes("interrupted by user") + normalized.includes("interrupted by user") || + // The Claude Agent SDK's ProcessTransport reports its child exiting this + // way whenever the subprocess is killed by SIGTERM/SIGINT — the signals + // used to request a graceful shutdown (e.g. desktop app quit). The OS can + // deliver that signal to the subprocess directly, ahead of our own + // stopSession()-driven close, so this case has to be recognized here too + // instead of relying solely on stopSession() winning the race. + normalized.includes("terminated by signal sigterm") || + normalized.includes("terminated by signal sigint") ); }