Skip to content

Make agent turns survive an orchestrator rollout, and unwedge terminal runs - #167

Merged
imaustink merged 2 commits into
mainfrom
fix/resumable-agent-turns
Jul 26, 2026
Merged

Make agent turns survive an orchestrator rollout, and unwedge terminal runs#167
imaustink merged 2 commits into
mainfrom
fix/resumable-agent-turns

Conversation

@imaustink

Copy link
Copy Markdown
Owner

An agent turn that was mid-flight during an orchestrator rollout still failed, even after 55b0e4b. That commit fixed three real defects and made the failure honest; it could not make it stop happening. This does.

Bundles three smaller fixes the investigation surfaced, at @imaustink's request.

1. Resumable agent turns (docs/adr/0033)

An agent turn's work lives in its own Job pod. The turn's wait lives in an orchestrator pod. Every push to main restarts the orchestrator — the original incident recorded 11 rollouts in 14 hours — and the drain window is 25s against a turn that takes minutes. Past that, core NATS discards the reply published into the gap, because nothing is subscribed. The answer existed, was correct, and was unrecoverable.

JetStream would fix it at the transport, but the deployed NATS has no jetstream block, so that means new storage plus new retention/consumer failure modes. Instead this uses the pod that already outlives the orchestrator — the agent's own — as the buffer:

  • The agent holds its concluding message until acked. New reply_ack down-message; publishHeld re-offers the identical envelope, seq included, every 10s until acked, giving up after 10min (AGENT_REPLY_ACK_TIMEOUT_MS, 0 disables). Questions are held too — a lost question strands a conversation as badly as a lost answer — and released as soon as their answer arrives. Narration isn't held.
  • The conversation is anchored to the run before the wait. persistSession runs after the graph returns, which is precisely the write a SIGKILLed pod never performs.
  • The next turn re-attaches. checkActiveAgentRun distinguishes "parked on a question" (publish the text as a prompt) from "owed a reply" (publish nothing, just collect), bounded at 45s since silence is diagnostic there — a live run heartbeats every 20s, a finished one re-offers every 10s. Route-driven turns re-attach too: without that, a re-applied trigger label dispatched a second run while the first held the answer.

An interrupted turn now reports a resumable pause, not an error.

2. Terminal runs that could never become terminal

markFailed — the only path that fails a run whose Job has vanished — hand-built its condition and appended it without LastTransitionTime, which the CRD requires:

AgentRun "10c84781-..." is invalid: status.conditions[0].lastTransitionTime: Required value

Every status update was rejected, so the run stayed Running, was re-reconciled every ~16 minutes, failed identically, and never became eligible for the retention sweep (which only reclaims terminal runs). Production held three AgentRuns and a ToolRun wedged this way for two days. Both Run controllers now use meta.SetStatusCondition, as every other controller in the package already did — it stamps the timestamp and replaces by type, which also closes the duplicate-Ready wedge (conditions is a map-list keyed by type). The wedged objects self-heal once this deploys.

3. The github image could never build

Two bugs in one layer. sha256sum -c <(grep ...) is a bashism that Debian's dash rejects outright — and underneath it, the tarball was saved as gh.tar.gz while sha256sum -c opens the name printed in the checksums file. This is what failed eff22d9b's release run and skipped its deploy.

This supersedes #166, which fixes only the bashism and would still fail on the filename mismatch — that PR was validated with dash -n but never built, so the second bug was invisible to it. Verified here by building the image and running gh --version (2.96.0) inside it.

4. A flaky HTTP test

Failed ~1 run in 10 with other side closed and zero bytes read — a request written to a dead socket, not a server fault. Node's fetch pools keep-alive sockets per origin; these tests listen on port 0 and close their servers, so a recycled ephemeral port hands the next test a corpse. Every request now sends connection: close (verified to defeat pooling: 3 requests, 3 sockets), applied to all four test files with that shape.

Verification

  • Go controller suite via envtest: 18 specs pass, and the new orphaned-run specs fail without the fix — only a real API server rejects the update, so a fake client could never have caught this.
  • All JS workspaces pass (orchestrator 483, agent-runtime 21, gateway 245).
  • github image builds; gh runs inside it.
  • Chart render confirms AGENT_REPLY_ACK_TIMEOUT_MS=90000 in e2e and absent by default.
  • The new resilience.e2e.ts spec is written but not executed — it is guarded by requireMinikubeContext and this environment points at the live cluster.

Known gaps (recorded in the ADR, not hidden)

  • The interrupted turn itself is still lost. The invocation record integration-gateway polls is an in-process Map; only the answer is recovered, on the next turn. Closing that needs durable invocation records.
  • A mixed deployment holds pointlessly. An orchestrator too old to send reply_ack never acks, so agents from this commit hold every concluding message for the full timeout before exiting — bounded, correctness-neutral, and why the timeout is env-tunable.
  • Agent-backed Tools (runTool) stay unresumable — no session slot to anchor them, matching the v1 cut already documented there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FUuaa2eY29JPocyiQZD9a8

imaustink and others added 2 commits July 26, 2026 07:37
An orchestrator rollout mid-turn still failed a healthy agent run. 55b0e4b
made that failure honest (transport loss stopped being relabelled as a
timeout) and bounded the shutdown drain at 25s, but the shape of the problem
was untouched: an agent turn takes minutes, the drain gives it seconds, and
core NATS discards the `reply` published into the gap because nothing is
subscribed. The answer existed, was correct, and was unrecoverable. Every
push to main deploys and restarts the orchestrator; the original incident
recorded 11 rollouts in 14 hours.

Rather than enable JetStream on the shared NATS (no `jetstream` block today,
so: new storage, retention and consumer failure modes), use the pod that
already outlives the orchestrator -- the agent's own -- as the buffer.

1. The agent holds its concluding message until acked. New `reply_ack`
   down-message; `publishHeld` re-offers the identical envelope, seq
   included, every 10s until the orchestrator acks that seq, giving up after
   10min (AGENT_REPLY_ACK_TIMEOUT_MS, 0 disables). Questions are held too --
   a lost question strands a conversation as badly as a lost answer -- and
   released as soon as their answer arrives. Narration is not held.

2. The conversation is anchored to the run BEFORE the wait, via
   markAgentRunAwaitingReply. persistSession runs after the graph returns,
   which is exactly the write a SIGKILLed pod never performs.

3. The next turn re-attaches. checkActiveAgentRun now distinguishes "parked
   on a question" (publish this turn's text as a prompt) from "owed a reply"
   (publish nothing, just collect), bounded at 45s since silence is
   diagnostic there -- a live run heartbeats every 20s, a finished one
   re-offers every 10s. A route-driven turn re-attaches too: without that, a
   re-applied trigger label dispatched a SECOND run while the first held the
   answer.

An interrupted turn now reports a resumable pause, not an error.

Known gaps, recorded in the ADR: the interrupted turn itself is still lost
(the invocation record integration-gateway polls is an in-process Map);
agent-backed Tools remain unresumable (no session slot to anchor them, the
same v1 cut already recorded there); and an orchestrator too old to ack makes
agents hold pointlessly until the timeout, which is why it is env-tunable.

Adds docs/adr/0033, unit coverage on both sides of the ack, and an e2e spec
asserting a rollout mid-turn costs the turn but not the answer -- recovered
on a follow-up trigger, with no second AgentRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUuaa2eY29JPocyiQZD9a8
…tests

Three unrelated defects, each verified against the thing it was breaking.

1. A run whose Job vanished never reached a terminal phase. markFailed --
   the ONLY path that fails an orphaned run -- hand-built its condition and
   appended it, omitting LastTransitionTime, which the CRD schema requires.
   Every status update was rejected ("status.conditions[0].lastTransitionTime:
   Required value"), so the run stayed Running, was re-reconciled every ~16
   minutes, failed identically, and never became eligible for the retention
   sweep (which only reclaims TERMINAL runs). Production had three AgentRuns
   and a ToolRun wedged this way for two days, logging that error on every
   reconcile.

   Both Run controllers now use meta.SetStatusCondition like every other
   controller here already did -- it stamps the timestamp and replaces by
   type, so it also fixes the second latent wedge (conditions is a map-list
   keyed by type, so a duplicate "Ready" would be rejected too). Covered by
   envtest specs that fail without the fix; a real API server is required to
   catch it at all, since only schema validation rejects the update. The four
   wedged objects self-heal once this deploys.

2. The github tool image could never build. Two bugs in one layer: the
   checksum was filtered through `<(...)`, a bashism that Debian's dash
   rejects outright ("Syntax error: \"(\" unexpected"), and underneath that
   the tarball was saved as gh.tar.gz while sha256sum -c opens the name
   printed in the checksums file. Fixed both; verified by building the image
   locally and running `gh --version` (2.96.0) inside it. This is what failed
   the release run for eff22d9 and skipped its deploy.

3. The HTTP server tests failed about one run in ten with "other side
   closed" and zero bytes read -- a request written to a dead socket. Not the
   server: Node's fetch pools keep-alive sockets per origin, these tests
   listen on ephemeral ports and close their servers, and a recycled port
   hands the next test a corpse. Every request now sends `connection: close`
   (verified to defeat pooling: 3 requests, 3 sockets), applied to all four
   test files with this shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUuaa2eY29JPocyiQZD9a8
@imaustink
imaustink merged commit 23b3050 into main Jul 26, 2026
1 check passed
@imaustink
imaustink deleted the fix/resumable-agent-turns branch July 26, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant