[Bug]: Backend crash leaves Claude agents running after their runs are marked cancelled#15357

opened21d agobyAallisonmahmood

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Summary

When the desktop backend dies abruptly (SIGKILL), the claude processes it spawned keep running. On restart, recovery marks their runs cancelled and assumes the processes are gone. They aren't. The agents carry on calling tools: running git, editing files, posting to GitHub. None of their output reaches T3. The user sees a cancelled thread while the agent keeps changing their machine.

Visual explainer with the full timeline: https://post.patchyhq.com/d/9yc2qhd8o4yy

Steps to reproduce

  1. In a Claude thread, ask the agent to run until [ -e /tmp/release ]; do sleep 1; done in the foreground, then run touch /tmp/acted and reply "done".
  2. While it waits, kill -9 the backend from a terminal outside T3 (its pid is in ~/.t3/userdata/server-runtime.json). Let the desktop restart it.
  3. Wait for the run to show as cancelled, then touch /tmp/release.

Expected behavior

The agent process dies with the backend, or recovery kills it before marking the run cancelled.

Actual behavior

The agent process is reparented to the user's systemd manager and keeps going. It creates /tmp/acted and replies "done". T3 shows neither.

From our repro (UTC):

TimeT3 recordedThe agent did
21:50:57.092Backend killed. The agent shares the backend's pgid and sid.Waiting in its shell loop
21:50:58.861Run cancelled, provider thread idle, no assistant itemStill alive, ppid now 1167 (systemd --user)
21:51:24.508NothingNext tool call: writes /tmp/acted
21:51:25.488NothingFinal reply written to its Claude transcript

In the original incident, two mid-turn agents went on for 4 and 7 minutes after the crash and filed GitHub issues that never appeared in T3. An idle agent with a Claude Code Monitor running also survived. Its Monitor fired 91 seconds after the crash, and the orphan ran a whole new turn on its own for about 10 minutes. Another idle agent, waiting on a plain background command, exited instead (that case is #15358). So what an orphan does depends on Claude Code internals.

Orphans reach the restarted backend but get refused. Their MCP client reconnects and gets 401 invalid_mcp_credential, which is right. The problem is that they're still alive.

Cause

  • ClaudeAdapterV2.ts:614 opens Claude through the SDK's query(). The SDK spawns the CLI without detached, so it shares the backend's process group and session, and nothing kills it when the backend dies abruptly. Graceful shutdown closes the query in a finalizer (ClaudeAdapterV2.ts:7314), but SIGKILL skips that.
  • ProviderRuntimeRecoveryService.ts:570 says // All provider processes are gone on startup/shutdown, then marks the runs cancelled (:306). There is no pid ledger, no reaping of orphans, and no way to reattach.

#14926 hit the same behaviour in the test suite: the fake Claude CLI was "reparented to PID 1 and runs until reboot", so it now exits when stdin closes. The real CLI keeps working after stdin closes mid-turn. PR #15323 handles SIGINT and SIGTERM but not SIGKILL, so orphans aren't covered there either.

Possible fix

Make that comment true. Either set PR_SET_PDEATHSIG on provider children (or spawn them through a small wrapper that does), or record each provider's pid and start time and kill survivors during startup recovery, before marking runs cancelled.

Impact

Major degradation or frequent failure

Version or commit

0.0.46-nightly.20261003.2632 (f391794a35c6), Orchestrator V2

Environment

Linux 7.2.5 (Arch-based), desktop AppImage, Claude Code 2.1.288 via Agent SDK 0.3.276, Claude Opus 5.5

Logs or stack traces

shell
1# Claude Code MCP log, 11 seconds after the backend was killed
2Error POSTing to endpoint: {"error":"invalid_mcp_credential","message":"A valid provider-scoped MCP bearer credential is required."}
3Server rejected the configured Authorization header (HTTP 401).
4
5# desktop.trace.ndjson, runBackendProcess exit cause
6Process interrupted due to receipt of signal: 'SIGKILL'

Workaround

None in T3. After a backend crash, check for claude processes whose parent is pid 1 (or your systemd user manager) and kill them by hand.

Related reports from the same incident: #15358 (a thread waiting on background work stays stranded after a restart), #15359 (a killed backend is logged as "Failed to read the exit status").

Filed by Allison Mahmood. Investigated and drafted with Claude Opus 5.5 in Claude Code.

Note

Grok responding on behalf of Julius.

Triage

Thanks for the precise report and the timeline, @allisonmahmood! The code on current main (5e35272f) matches what you found. A kill -9 of the backend skips every finalizer, and startup recovery then marks the run cancelled while the claude process is still alive.

What happens

ClaudeAgentSdkQueryRunner.open calls query() without a spawnClaudeCodeProcess (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts). The child shares the backend's process group and session and gets no parent-death signal. On Linux, Effect spawns the desktop backend detached, so the group id is the backend's pid, and kill -9 of that pid doesn't signal the group. The claude process gets reparented (to systemd --user in your trace) and keeps going.

Graceful shutdown does close the query. closeSession calls query.close() through a session-scope finalizer, but SIGKILL never runs it. Merged #14926 only made the fake test CLI exit when stdin closes. The real CLI keeps working after that, which is what your /tmp/acted row shows.

On the next boot, ProviderRuntimeRecoveryService assumes the processes are gone (the comment around line 570), writes run.updated with status: "cancelled", and idles the provider thread. No pid is recorded and nothing is reaped first. The invalid_mcp_credential 401 is the new server correctly refusing the orphan's MCP reconnect. Local tools don't go through that check, so git, file edits, and gh carry on with nothing written back to the thread.

The idle Monitor case is the same orphan. #15358 is the opposite situation, where the process really is gone and the thread is never told, and #15359 is only about the log line. Open #15323 handles SIGINT and SIGTERM but doesn't record or kill a process that outlived SIGKILL. Upstream, claude-agent-sdk-typescript#142 asked for parent-death termination and was closed as a duplicate.

Likely fix area

  • On Linux, one option is a wrapper that sets PR_SET_PDEATHSIG and then execs, installed through spawnClaudeCodeProcess. It doesn't carry over to grandchildren, and macOS has no prctl.
  • Another option works across platforms and already exists in the tree as OpenCodeServerLedger. Record the pid, start time, and command, then on the next start kill only a process that still matches, before the cancelled run.updated events are committed. A group kill would need the same start-time check, because the dead backend's pid can be recycled.

Until then, killing leftover claude processes whose parent is pid 1 or the systemd user manager is the right workaround. A maintainer will decide on the fix direction.

Synced from GitHub

Another reproduction from daily use. It adds one consequence the report doesn't cover: after recovery, restart continuation starts a second agent in the orphan's worktree, so two agents work one checkout.

For example, on 2026-10-06 the backend was SIGKILLed at 21:41:08 UTC. Recovery marked the runs cancelled, and continuation sent "Continue where you left off" to a fresh Claude session on each thread. Meanwhile the original claude processes, reparented to the systemd user manager, kept working in the same worktrees.

What two agents in one checkout did

  • A paid call twice. On one thread, the new session pressed the same paid video-generation button 33 seconds after the orphan did, which billed a second clip.
  • Interleaved commits. Both sessions committed on one branch. One commit picked up the other's uncommitted edit.
  • Duplicate GitHub issues. Each session filed the same follow-up issue.
  • The orphan held the real work. On another thread, the orphan kept committing and opened a pull request after T3's run had ended. T3 showed that thread as failed, and a person's retry got "The provider could not start this turn". Stopping the orphan would have lost its uncommitted work, and waking the thread would have added a second agent.
  • The new session couldn't see it. In every case, T3's timeline showed only the new session.

Environment and frequency

  • Version: t3code-nightly-bin 0.0.46_nightly.20261006.2735, Orchestrator V2, desktop on Arch Linux (kernel 7.2.5), Claude Code via the Agent SDK.
  • desktop.trace.ndjson logs "Process interrupted due to receipt of signal: 'SIGKILL'" for the backend at 2026-10-06 21:41:08, 2026-10-07 19:51:49, 2026-10-07 21:07:23 and 2026-10-08 04:40:24 UTC.
  • We don't know what sent the SIGKILLs. We didn't send them by hand.

For #15450 The ledger reap would prevent this, because it runs before recovery and so before continuation. Two suggestions:

  • Keep continuation strictly after the reap.
  • Note in the thread when a previous session was stopped. The orphan can leave uncommitted changes in the worktree that the continued session should know about.

Our workaround until then: a pre-tool hook refuses edits, commits, pushes and paid calls while another claude or codex process has the same working directory, and names that process so whoever coordinates can pick one.

Synced from GitHub

Another reproduction, on macOS, triggered by an in-app update rather than a crash. PR #15450 covers Linux only (its notes say macOS wasn't run live), so this may help scope the macOS half.

What happened (T3 Code Nightly, macOS, Claude provider, 2026-10-16)

  • 15:00:43Z: a thread's Claude session opens (query.open with sessionId) and starts a long turn.
  • About 15:02: an in-app update restarts T3 mid-turn.
  • 15:02:54Z: the restarted backend opens the same thread again (query.open with resume set to the same session id) and offers "Continue where you left off." The log has no query.close for the first query.
  • The first claude process (--session-id <id>) is still alive, now parented by launchd (ppid 1). The new one runs --resume <id> under the new backend. Both write to the same session transcript.
  • The orphan kept working for 40 minutes, unseen in the thread: it renamed a git branch in a worktree, rewrote files and launched new background work while the resumed session did the same job in parallel. It stopped only when killed by hand.

So on macOS the update path does what this issue describes for a SIGKILL on Linux, including the second-agent effect @smagnetize reported.

Workaround we run meanwhile: a Claude Code SessionStart hook that stops any claude process whose command line carries the same session id and whose parent is pid 1, skipping its own ancestors. It's the triage's second option (a pid ledger) done from the agent side, and it would be redundant once the server reaps its own agents on every platform.

Synced from GitHub

Another macOS reproduction from an in-app update, with one detail that may help scope the fix: the update path's graceful stop ends in SIGKILL when the backend is busy, so it lands in the same case as a crash.

Timeline (T3 Code Nightly, macOS, updating to 0.1.1-nightly.20261018.3335; Claude Code 2.1.303; times from desktop.trace.ndjson, UTC)

  • 04:32:40: desktop.updates.installDownloadedUpdate → desktop.backendInstance.stop.
  • 04:32:46: desktop.backendInstance.finalizeRun. That is about 6 s later, just past DEFAULT_BACKEND_TERMINATE_GRACE (5 s, then forceKillAfter). About seven Claude threads were open and several were mid-turn, so the backend most likely didn't finish closing its queries and was force-killed.
  • 04:32:50: the app relaunches. At 04:33:00 the new backend runs claude --resume=<id> for each thread at once.

Result

Three threads ended up with two live claude processes on the same session id. The old ones had ppid 1, the new ones the new backend. Both appended to the same transcript .jsonl. The orphans kept working for 25 to 40 minutes, invisible in T3:

  • One launched a Codex worker that the resumed session later believed it had launched itself.
  • One launched a second review round and stopped a worker that was editing the same worktree.
  • One held a test suite on a remote machine.

Nothing was lost only because we noticed and had each resumed session read the transcript, take over the orphan's pending work, and kill it.

Workaround we run meanwhile

This is similar to the one described above. A Claude Code SessionStart hook finds any other claude process with --input-format stream-json and the same --session-id/--resume value that isn't one of its own ancestors. It then injects its PID and child processes into the new session's context, so the session hands the work over before killing the orphan. It doesn't kill anything itself, because killing the orphan outright would drop in-flight work.

A server-side fix probably needs both halves: reap or reattach surviving agents before resuming a session, and give the update path's stop enough time, or a clean kill of the provider children, so it never escalates to SIGKILL with agents still attached.

Synced from GitHub

Current-source update to the cause analysis above: the Claude launch and startup cleanup have changed since the original report, but macOS still bypasses the leftover-agent cleanup.

I inspected the latest published nightly, v0.1.1-nightly.20261020.3411 at d36b7da2485bc889b320a285deea181f637d760a, and refreshed main at 6ab39ff6b8f60447c2580b818351fe4387fffa82. The relevant Claude spawn, agent-scope, and desktop shutdown files are byte-identical between those revisions.

  • The default POSIX Claude launch now uses spawnClaudeProcessGroup, which sets detached: true. The query runner selects this function outside Windows at lines 921–924. Its child-exit callback kills the remainder of the group, but that callback runs in the owning server and cannot run after that server dies. This updates the original diagnosis that Claude shares the backend's process group.
  • AgentScope.make returns the no-op default on every non-Linux platform. The stopLeftoverAgents startup reap in that file therefore does not run on macOS. Its own comment states the intended invariant: stop agents left by a killed server before resumed sessions work in the same checkout.
  • The desktop update path explicitly requests backend stops before quitAndInstall. Normal quit/relaunch also waits for shutdown in apps/desktop/src/app/DesktopLifecycle.ts. The backend still has a five-second termination grace in apps/desktop/src/backend/DesktopBackendManager.ts. These calls establish intended cleanup; they do not establish that the update incidents above timed out or identify their actual termination signal.

As a bounded mechanism check, I extracted the current spawn function, removed only its TypeScript signature annotations, and ran it with a harmless Node heartbeat provider that continues after stdin EOF. Once per baseline, killing only the captured stand-in server PID with SIGKILL left the provider alive under PPID 1 with advancing heartbeats. It overlapped a second manually launched stand-in provider. In the control, SIGTERM triggered the stand-in server's AbortController and its provider exited. All fixture processes were stopped afterward.

This was a process-level stub check, not a full T3 Code/real-Claude reproduction. It does not establish real Claude's EOF behavior, automatic restart continuation, or the cause of a particular app restart. The useful source distinction is that current process-group cleanup is parent-owned, while startup reclamation remains Linux-only. The existing macOS incident evidence in this issue is still needed to connect that mechanism to real provider activity.

Note

🤖 Claude Opus 5.5 responding on behalf of Theo

Thanks for the report. One part of this is fixed on main in #17662. Please test on the nightly built from main on 2026-10-21, or on the next stable release. One part is still open: reap orphaned Claude agents on macOS before recovery or resume. If that part still matters to you, please open a new issue for that part only.