fix(cluster): let an ACP write survive a shim channel renewal - #2136
spacedragon merged 5 commits into
Conversation
A credential renewal aborts the requests in flight on the socket it replaces, which is ordinary and documented: the reply was lost, ask again. The ACP stdin write could not act on that. Its request rode inside a `WritableStream.write`, and a `WritableStream` that rejects a write stays errored — every later write rejects with the SAME stored error. The sandbox stayed healthy, `AcpHost` stayed warm, and every turn after that one unlucky write answered "Agent failed to respond: shim channel renewed", new sessions included, until the pod was deleted by hand. Asking again is only safe if the far side can tell a re-send from a new write: applying ND-JSON twice corrupts the stream the retry means to save. So chunks carry a per-stream `seq`, the runner applies one at most once (recorded after the write, so a failed write is still owed), and the shim announces the dedupe in its `open` reply. A shim that does not announce it is not asked twice — there the write ends the runtime instead, which `reapTerminalHost` respawns on the next message. One failed turn, never an agent that has gone quiet for good. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ls terminate §6 explains that a frame lost to a renewal must not be re-sent, and terminates the stream instead. The ACP stream cannot take that answer — terminating it ends the runtime, and the write in flight is usually the turn — so it buys the missing property with a per-stream seq the shim dedupes. Stating both halves together keeps the next reader from "fixing" one into the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client serves requests concurrently, and a chunk that reached the pod just before the socket closed can still be inside its stdin write when the daemon's re-send lands on the new socket. Both then read `appliedSeq` before either has recorded it, and the dedupe lets the duplicate through — the corruption it exists to prevent. Chunk writes now run in arrival order, so the re-send reads the result of the attempt it duplicates. Also pins `ShimSession.waitForAttach` against the real session rather than a fake. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI typechecks tests through tsconfig.typecheck.json, where the `emit` callback inside an `as never` cast is an implicit `any`. Typed as `ShimEvent['event']` and the cast dropped — the runner's own deps type is what the test wants anyway. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
The sequence-based retry and shim capability handshake address the ambiguous-write case cleanly, but the new terminal fallback can leave the actual ACP child running in the sandbox. Because the synthetic exit clears AcpHost’s spawned-runtime handle before host reaping calls stop(), a subsequent message can launch a second adapter beside the orphaned one. Please make the failure path stop/close the remote runner before publishing terminal exit, or otherwise preserve a handle that teardown can use.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| const failRuntime = (reason: string): void => { | ||
| opts.log.warn(`cluster: ACP stream for agent ${opts.session.agentId} ended — ${reason}`) | ||
| opts.session.offEvent(onEvent) | ||
| finish() |
There was a problem hiding this comment.
[P1] Stop the sandbox runner before publishing terminal exit
failRuntime() only detaches the event listener and calls finish(). That fires SpawnedRuntime.onExit; AcpHost then clears its spawned handle before reapTerminalHost() reaches host.stop(), so teardown no longer sends this stream an op: 'close'. When the retry fails after a renewal (for example, a second renewal, timeout, or shim error), the AcpRunner and its child can therefore remain alive in ShimClient.acpStreams, and the next message spawns another adapter in the same pod. Please close/stop the remote stream before firing the terminal notification, or keep the spawned handle available until host teardown has invoked stop().
There was a problem hiding this comment.
Right — the exit finish() publishes clears AcpHost's spawned handle, so stop() early-returns and this stream never gets its close. Fixed in 5c727b4: the failure path now shares teardown's close (closeStream, same stopped latch so a later stop() does not ask twice) and awaits it before the exit goes out. The fallback test now asserts the order ['close:s1', 'exit'] and that a following stop() sends nothing further; it fails on the previous head.
…untime's exit The exit `failRuntime` publishes clears AcpHost's spawned handle, so the host teardown that follows (`reapTerminalHost` → `stop()`) returns early and never sends this stream a `close`. The adapter kept running in the pod, and the next message launched a second one beside it. The failure path now shares the teardown's close (same `stopped` latch, so a later `stop()` does not ask twice) and awaits it before the exit goes out. Pinned by ordering the fake session's `close` ahead of the exit listener. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
The ACP write-resume path now safely deduplicates ambiguous writes across shim renewal, preserves backpressure and compatibility gating, and terminates the remote child before publishing a synthetic runtime exit. The follow-up fixes the prior orphaned-runner finding by sharing the close path and verifying close-before-exit ordering. I found no remaining blocking issues in this revision.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
|
Thank you. Merging. |
Summary
A bug fix for a cluster agent that stops answering for good after one unlucky credential renewal, observed on our deployment on 2026-09-17: every turn — new sessions and replies alike — failed with
⚠️ Agent failed to respond: shim channel renewedfor hours while the sandbox pod sat there healthy, until someone deleted the pod by hand.What happens
The shim credential renews at half its TTL, and
ShimSession.attachaborts the requests in flight on the socket it replaces. That is documented and correct: the reply was lost, ask again (ShimChannelLostErroris typed for exactly that). The ACP stdin write is the one caller that could not act on it. Its request rides inside theWritableStream.writeofcreateRemoteRuntime, and aWritableStreamthat rejects a write stays errored — every laterwrite()rejects with the same stored error object. TheShimSessionsurvives the renewal (that is what it is for), the runtime never exits, soAcpHoststays warm on a dead stdin and every subsequent turn dies on its first byte.The tell in the daemon log: repeated
relay im dispatch failed …: ShimChannelLostError: shim channel renewedwith an identicalShimChannel.abort ← ShimSession.attachstack, at times with no matchingshim: bound agent …line. Same error, replayed.Fix
Asking again is only safe if the pod can tell a re-send from a new write — ND-JSON applied twice is a corrupted frame, which is why §6 of
cluster-spawn-and-shim.mdterminates a tunnel stream in the same situation. Terminating the ACP stream is not acceptable there (it ends the runtime, and the write in flight is usually the turn), so this stream buys the missing property instead:shim/acp-stream.ts— chunks carry an optional per-streamseq.shim/acp-runner.ts— aseqis applied at most once, recorded after the write so a failed write is still owed. Chunk writes run in arrival order: the client serves requests concurrently, and a chunk that reached the pod just before the socket closed can still be inside its stdin write when the re-send lands on the new socket.shim/client.ts— theopenreply announcesresumableWrites: true. Announced rather than assumed: against a shim that predates it the daemon must not ask twice.k8s/remote-runtime.ts— writes are numbered; a write rejected withShimChannelLostErroris re-sent unchanged (seq included) once the session is attached. When it cannot be re-sent — no announcement, no re-attach within the grace window, or the re-send fails too — the runtime is ended deliberately, whichreapTerminalHostrespawns on the next message. One failed turn is an acceptable outcome; an agent that answers nothing until a human intervenes is not.shim/session.ts—waitForAttach(timeoutMs). Ordinarily already satisfied when the rejection arrives (attachbinds the replacement before aborting the old channel), so this is the ordering guarantee rather than a wait anyone expects to spend time in.Backward compatible in both directions: an older shim ignores
seqand does not announce, so the daemon falls back to the terminal path; an older daemon never sendsseq, so the new shim's dedupe is inert.docs/designs/cluster-spawn-and-shim.md§6 gains a subsection stating why the ACP stream re-sends where the tunnels terminate, so the next reader does not "fix" one into the other.Testing
packages/daemon/test/shim-acp-write-resume.test.ts(new, 7 tests):AcpRunnerwithcatas the runtime — the evidence is what reached stdin, not what the shim answered: a re-sent chunk is applied once; a re-send arriving while its first attempt is still writing is still applied once; a re-send whose first attempt failed is applied.ShimSession.waitForAttachon the real session: settles at once when attached, on the next attach otherwise, rejects on the grace window and once the launch is lost.createRemoteRuntime: a write the renewal failed goes out again unchanged (seq1, 2, 2); against a shim that did not announce the dedupe the runtime exits instead of the stream erroring forever.Five of the seven fail on an unmodified checkout.
shim-pod-env,shim-provider-env,shim-dsh-preset,shim-handshake,cluster-metricspass alongside (113 total).tsc --noEmit, eslint and Prettier clean on the touched files.shim-cancellation.test.tsfails 5 cases on this macOS host identically on an unmodified checkout (needs tooling the host lacks).The production incident was cleared by deleting the sandbox pod (generation 26 → 27, session resumed via
session/load); this goes onto our deployment next.🤖 Generated with Claude Code