Make agent turns survive an orchestrator rollout, and unwedge terminal runs - #167
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainrestarts 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 thereplypublished 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
jetstreamblock, 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:reply_ackdown-message;publishHeldre-offers the identical envelope,seqincluded, every 10s until acked, giving up after 10min (AGENT_REPLY_ACK_TIMEOUT_MS,0disables). 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.persistSessionruns after the graph returns, which is precisely the write a SIGKILLed pod never performs.checkActiveAgentRundistinguishes "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 withoutLastTransitionTime, which the CRD requires: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 usemeta.SetStatusCondition, as every other controller in the package already did — it stamps the timestamp and replaces by type, which also closes the duplicate-Readywedge (conditionsis a map-list keyed by type). The wedged objects self-heal once this deploys.3. The
githubimage could never buildTwo bugs in one layer.
sha256sum -c <(grep ...)is a bashism that Debian's dash rejects outright — and underneath it, the tarball was saved asgh.tar.gzwhilesha256sum -copens the name printed in the checksums file. This is what failedeff22d9b'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 -nbut never built, so the second bug was invisible to it. Verified here by building the image and runninggh --version(2.96.0) inside it.4. A flaky HTTP test
Failed ~1 run in 10 with
other side closedand zero bytes read — a request written to a dead socket, not a server fault. Node'sfetchpools 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 sendsconnection: close(verified to defeat pooling: 3 requests, 3 sockets), applied to all four test files with that shape.Verification
githubimage builds;ghruns inside it.AGENT_REPLY_ACK_TIMEOUT_MS=90000in e2e and absent by default.resilience.e2e.tsspec is written but not executed — it is guarded byrequireMinikubeContextand this environment points at the live cluster.Known gaps (recorded in the ADR, not hidden)
Map; only the answer is recovered, on the next turn. Closing that needs durable invocation records.reply_acknever 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.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