Repository navigation
The last pass writes, and then ships what it wrote (ADR 0079) - #116
Merged
Merged
Conversation
Shipping was a lifecycle task cancelled by the same context the signal cancels, so from SIGTERM onward the shipper did nothing. The shutdown flush ran after `wg.Wait()` — after the shipper had already returned. So the agent's last act was to write the journals and the inventory it had stayed alive to write, into a directory nothing would read again. That is loss rather than delay because the spool is an emptyDir by decision (ADR 0007/0026). A container restart keeps it and ADR 0072/0077 read it back; a pod replacement — drain, upgrade, eviction — takes it. With no backend configured, which is every installation today, a replaced pod loses the whole open hour. `runShutdown` flushes and then offers the spool once, bounded by `FinalRoundBudget` on a fresh context. One cap, not two: `post` derives its deadline from the round's and the earlier wins, and `Round` already stops at the first payload the backend would not take. Four seconds because the chart floor must not move — ADR 0068 §4 measured the flush path at ~10s, and 10+4 < 15, so installs pinned at 15 are unaffected. That ADR asked for the grace period to be a number "a later change to the shutdown path has to be measured against"; the measurement is now a test that fails if the budget outgrows the floor, checked by raising it to 6s: "the chart accepted 15s, which does not cover a 10s flush pass plus a 6s shipping round". No final round when halted on a 401/403, none when no backend is configured, and delivery semantics are untouched. The round's own result can only be logged: the coverage payload is written by the flush before it, and nothing scrapes an exiting process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeZECJXTDHL6Nts95eKMJA
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.
The controller's shutdown had two halves that could not help each other.
Shipping was a lifecycle task cancelled by the same context the signal cancels —
Shipper.Runreturns onctx.Done()andRoundbails onctx.Err(), so fromSIGTERM onward it does nothing. The shutdown flush ran after
wg.Wait(), whichis after the shipper had already returned.
So the agent's last act was to write the journals and the inventory it had
stayed alive specifically to write, into a directory nothing would read again.
Not "we do not ship on the way out" — we specifically did not ship the thing we
specifically stayed alive to write. ADR 0068 §4 sized the grace period around
that flush and made it the reason the floor is 15 seconds.
Why it is loss and not delay
The spool is an
emptyDirby decision (ADR 0007, ADR 0026), asserted byTestTheSpoolIsAlwaysAnEmptyDir. A container restart keeps it, and sinceADR 0072 / ADR 0077 the next process reads its open windows back. A pod
replacement — node drain, chart upgrade, eviction — takes the directory with the
pod.
How much goes depends on shipping:
security.md§5)What was in the way
ADR 0075's rule:
Runreturns only on cancellation, so a backend that is down,hostile or absent never reaches the agent's
cancel(). A final round is bydefinition a network call on the exit path, and the value of that rule is
precisely that a backend cannot influence the agent's lifecycle. A round that
could block would be a backend holding somebody's node drain open.
The change
runShutdownflushes and then offers the spool once, on a fresh context boundedby
FinalRoundBudget. The order is the decision, so it lives in one namedfunction that is tested directly rather than in two statements at the end of a
long one.
One cap, not two. Two were considered.
postderives its own deadline fromthe round's and the earlier of the two wins, so no request in flight can outlive
the budget; and
Roundalready stops at the first payload the backend could nottake, so a shorter per-request timeout would buy nothing on top.
Four seconds, because the floor must not move. ADR 0068 §4 measured the
flush path at about ten seconds. Ten plus four is fourteen, under the existing
floor of 15, so installations that set the grace period explicitly to 15 are
unaffected. A knob was rejected: set to sixty by someone reasoning locally, it
turns a node drain into a wait for a backend that is never coming back — the
same argument that makes cadences constants in ADR 0073.
The floor is now asserted against the constant. ADR 0068 §4 asked that the
grace period be "a number that was chosen, and that a later change to the
shutdown path has to be measured against". This is that change, and the
measurement is a test rather than a memory. Checked by raising the budget to 6s:
What the round does not do. No final round when the shipper is halted on a
401/403 — nothing retries into a rejected credential, and a fleet's worth of
final rounds arriving at a backend that already said no is the worst version of
it. No change to delivery semantics: a payload leaves the spool only on a 2xx,
one request per natural key, undelivered files stay. Nothing at all when no
backend is configured, so today's installations write their last pass exactly as
before.
Its result can only be logged. The coverage payload is written by the flush
that runs before this round, so it cannot carry the round's own outcome, and
writing coverage afterwards would produce a payload guaranteed never to be sent.
Nothing scrapes the metrics endpoint of an exiting process. So the log carries
delivered, deferred, and whether the budget ran out — the last of which is how
anyone would learn that four seconds was the wrong number. Stated in the ADR as
a property of the order rather than left as a gap.
Tests
The safety property first, because everything else about this round is best
effort and this is not:
TestAHangingBackendCannotHoldUpShutdown— a backend that never answers;the round returns inside its budget and the payload stays in the spool.
TestTheShutdownPassShipsAfterItHasWritten— the order, which is the fix.TestAHaltedShipperRunsNoFinalRound— no requests into a rejected credential.TestTheFinalRoundDeliversWhatItCanAndKeepsTheRest.TestTheShutdownPassWritesWithNoBackendConfigured.TestTheGracePeriodFloorCoversTheShutdownPath— the constant coupling above.Checks
go test ./...green from a cleangit archive HEADextraction.GOOS=linux golangci-lint run ./...: 3 gosec G115 findings, all three presenton
origin/main. No new ones.helm lintpasses.Not here
The spool is still an
emptyDirand still loses what a replaced pod could notdeliver. This shrinks that set; it does not make the volume durable. A hard kill
still runs no pass at all.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KeZECJXTDHL6Nts95eKMJA