Skip to content

The last pass writes, and then ships what it wrote (ADR 0079) - #116

Merged
kfeofantov merged 1 commit into
mainfrom
feat/ship-what-is-left
Sep 10, 2026
Merged

kfeofantov merged 1 commit into
mainfrom
feat/ship-what-is-left

Conversation

@kfeofantov

Copy link
Copy Markdown
Contributor

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.Run returns on ctx.Done() and Round bails on ctx.Err(), so from
SIGTERM onward it does nothing. The shutdown flush ran after wg.Wait(), which
is 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 emptyDir by decision (ADR 0007, ADR 0026), asserted by
TestTheSpoolIsAlwaysAnEmptyDir. A container restart keeps it, and since
ADR 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:

lost when the pod is replaced
backend configured and healthy the tail since the last delivery, plus the shutdown flush itself
no backend configured (every install today, security.md §5) the whole open hour, plus any closed windows still in the spool
backend down for a while everything since it went down

What was in the way

ADR 0075's rule: Run returns only on cancellation, so a backend that is down,
hostile or absent never reaches the agent's cancel(). A final round is by
definition 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

runShutdown flushes and then offers the spool once, on a fresh context bounded
by FinalRoundBudget. The order is the decision, so it lives in one named
function that is tested directly rather than in two statements at the end of a
long one.

One cap, not two. Two were considered. post derives its own deadline from
the round's and the earlier of the two wins, so no request in flight can outlive
the budget; and Round already stops at the first payload the backend could not
take, 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:

--- FAIL: TestTheGracePeriodFloorCoversTheShutdownPath
    the chart accepted 15s, which does not cover a 10s flush pass plus a 6s shipping round

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 clean git archive HEAD extraction.
  • GOOS=linux golangci-lint run ./...: 3 gosec G115 findings, all three present
    on origin/main. No new ones.
  • helm lint passes.

Not here

The spool is still an emptyDir and still loses what a replaced pod could not
deliver. 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

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
@kfeofantov
kfeofantov merged commit 3e4de98 into main Sep 10, 2026
4 checks passed
@kfeofantov
kfeofantov deleted the feat/ship-what-is-left branch September 10, 2026 18:49
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