fix: [sc-106112] Stop one undeliverable postback from blocking the rest of the spool - #103
Merged
mlataza merged 1 commit intoAug 13, 2026
Conversation
The spool flush stopped at the first entry that failed, on the assumption that a failure meant the engine was unreachable. But attemptPostback returned done=false for any 5xx or unparseable body, not only for connectivity failures, so a single entry the engine consistently rejected blocked every entry behind it: the flush restarted from the same poisoned entry on every connection cycle, failed, and returned. There was no per-entry attempt counter, so it was retried forever, and the newer results stranded behind it were eventually discarded by the age check -- silently losing exactly the command results the spool was added in sc-97780 to protect, and logging the loss as "expired", which reads like stale-data cleanup rather than a delivery failure. Split the failure into two outcomes at the one place that can tell them apart. attemptPostback now classifies each attempt: deliveryDone (accepted, already fulfilled, or permanently rejected), deliveryUnreachable (a transport error, or a connection that broke mid-response), and deliveryRetryEntry (the engine answered but would not take this request). In-line retries treat both failures alike, as before. The flush does not: unreachable still stops it early -- attempting the rest is pointless and that optimization is worth keeping -- while a rejection passes over the entry and keeps delivering the ones behind it. A rejected entry keeps its place in the queue with a persisted attempt counter and last error, so its budget survives an agent restart, and is abandoned once the budget is spent: removed under its own drop reason, counted separately, and surfaced with a best-effort AgentPostbackAbandoned notification, following the precedent set for exhausted in-line retries. Drop reasons are now counted individually (expired / capacity / attempts_exhausted / corrupt) rather than sharing one number, because a spool shedding entries under pressure and one abandoning a result the engine refuses are diagnosed very differently. Rejections count against the budget at most once every 10 minutes. An engine failing wholesale answers 5xx for every entry, which at the HTTP layer is indistinguishable from it rejecting each one specifically; without spacing, a flapping connection could spend a budget in minutes and abandon results the engine would have accepted on recovery -- worse than the behaviour being replaced. Spacing bounds the budget in wall-clock time rather than in reconnects. It never delays the pass-over itself: a rejected entry is skipped on every flush regardless, and only the counting is spaced. Entry files written by an older agent have no attempt counter and simply read as never attempted, so an upgrade delivers them rather than discarding them.
mlataza
deleted the
feature/sc-106112/stop-one-undeliverable-postback-from
branch
August 13, 2026 18:53
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.
Problem
The spool flush stopped at the first entry that failed, on the assumption that a failure meant the engine was unreachable. But
attemptPostbackreturneddone=falsefor any 5xx or unparseable body, not only for connectivity failures. So one entry the engine consistently rejected blocked every entry behind it: the flush restarted from the same poisoned entry on every connection cycle, failed, and returned. No per-entry attempt counter existed, so it was retried forever, and the newer results stranded behind it were eventually discarded by the age check — silently losing exactly the command results the spool was added in sc-97780 to protect.The loss was also misreported. It surfaced as
reason=expired, which reads like stale-data cleanup rather than a delivery failure, so "workflow made changes but reported nothing" had no signal pointing at a cause.Changes
Split the failure into two outcomes at the one place that can tell them apart.
attemptPostbacknow classifies each attempt asdeliveryDone(accepted, already fulfilled, or permanently rejected),deliveryUnreachable(transport error, or a connection that broke mid-response), ordeliveryRetryEntry(the engine answered but would not take this request). In-line retries treat both failures alike, exactly as before. The flush does not: unreachable still stops it early — attempting the rest is pointless and that optimization is worth keeping — while a rejection passes over the entry and keeps delivering the ones behind it.Durable attempt budget. A rejected entry keeps its place in the queue with a persisted attempt counter and last error, so its budget survives an agent restart. Once spent, the entry is abandoned: removed under its own drop reason
attempts_exhausted, counted separately, and surfaced with a best-effortAgentPostbackAbandoned:<post_id>notification, following the precedent set for exhausted in-line retries. Drop reasons are now counted individually (expired/capacity/attempts_exhausted/corrupt) rather than sharing one number.Spacing, which was not in the ticket. Reviewing the change surfaced a regression it would otherwise have shipped: an engine failing wholesale answers 5xx for every entry, which at the HTTP layer is indistinguishable from it rejecting each one specifically. With a flapping connection, reconnects are minutes apart, so a plain per-cycle counter could spend an entry's whole budget in minutes and abandon results the engine would have accepted on recovery — worse than the behaviour being replaced, since today those results survive to the 24h age bound. Rejections therefore count at most once every 10 minutes, bounding the budget in wall-clock time rather than in reconnects (a result survives ≥40 min of a wholesale outage however often the agent reconnects). This never delays the pass-over itself: a rejected entry is skipped on every flush regardless; only the counting is spaced.
Backward compatibility. Entry files written by an older agent have no attempt counter and read as never-attempted, so an upgrade delivers them rather than discarding them as corrupt.
Verification
Falsification of both new guards (disposable detached worktrees; the author worktree was never mutated):
TestSpool_PoisonedEntryDoesNotBlockHealthyEntrieswent red with exactly the ticket's symptom,expected every entry attempted in one flush, got [poison], alongsideentry "b" was stranded behind the poisoned entryand the abandonment test. Restored, green.TestSpool_RapidRejectionsDoNotBurnTheAttemptBudgetwent red:entry abandoned after only 2 spaced attempts. Restored, green.Tests against real HTTP servers, not stubbed deliverers.
TestFlushPostbackSpool_PoisonedEntryDoesNotBlockOthersdrives the ticket's step-3 scenario through the production stack: an engine that 503s exactly onepost_idand 200s the rest. All three results reach the engine in a single flush, in order, and only the rejected entry stays spooled.TestAttemptPostback_TransportFailureIsUnreachableandTestFlushPostbackSpool_StopsEarlyWhenEngineUnreachablepoint at a closed port for a genuine connection-refused, confirming the early-stop path survives and drops nothing.TestAttemptPostback_ClassifiesOutcomespins the mapping directly rather than only through its consequences — collapsing "rejected this request" back into "unreachable" is the defect itself, so it is asserted at the source.Unit coverage coming from the ACs: a poisoned entry does not block healthy entries; stranded entries are delivered rather than aged out; a transport failure still stops the flush early and consumes no attempt; an entry is abandoned after the max attempts with the distinct reason, counter and callback; the attempt counter survives a simulated restart; an old-format entry file is delivered rather than discarded; a corrupt entry is still discarded; and expired / capacity / attempts / corrupt drops are each counted under their own reason.
go test ./...,go vet ./...,golangci-lint run ./...all clean;GOOS=windowsandGOOS=linuxcross-builds clean.Not probed live, and worth being explicit about why — unlike sc-106111, no live probe of the shipped binary was reachable here:
CreatePostbackRequesthardcodeshttps://, so a local stub engine would need a TLS certificate trusted by the host — which is also what stands between this ticket and an integration-workflow scenario. The stub-release fixture used by sc-106110 gets away with plain HTTP because the release URL is configurable; the postback scheme is not.The closest evidence is the httptest-backed tests above: real sockets, real TCP, real transport errors, real production code path — but under
go test, not a running agent. Residual risk is in the wiring between a live connection cycle and the flush, which this change does not alter (only the callback's return type), rather than in the flush logic itself.Follow-up
An end-to-end integration scenario needs a TLS-terminating stub engine trusted by each runner (or a build-time-gated postback scheme override on the same pattern as
releaseUrlOverrideFileStr). Worth its own ticket — it would unlock QA automation for this whole class of postback behaviour, not just this fix.