fix(escalate/serve): a burst of taps must not lose five of six resolves - #283
fix(escalate/serve): a burst of taps must not lose five of six resolves#283itsHabib wants to merge 5 commits into
Conversation
Measured 2026-09-05: six Slack taps inside ~10s, one resolved and five failed — four on `state_lock_timeout after 10s`, one killed at the 25s attempt cap after waiting on gate's lock twice (the `gate next` lookup and the resolve). serve ran every callback in its own goroutine, so the taps contended for gate's single-writer state lock with each other. Each failure line said the judgment was unspent and a retry legal; nothing retried, and the cards said the decisions had failed. Two mechanisms, both inside serve — gate's lock semantics and its 10s timeout are untouched, and no authorization step moves: - One process-wide queue. Every background callback passes through a single slot, so two gate invocations never run against one state dir at once. This subsumes the per-escalation lock it replaces: same-escalation taps are still serialized, and now different escalations are too. - Retry the lock, and only the lock. `state_lock_timeout` is the one failure gate takes before any append, so it recorded nothing. serve names it ErrStateBusy (read off gate's own output, never imported — the grant lookup's `gate next` too) and retries four times over ~90s. Every landed decision (0..3) and every other failure is reported on the first try, because retrying one of those could double-apply. The card stays honest: queued while waiting a turn, retrying while riding out the lock, and "NOT recorded" only once the retries are spent — naming that nothing was spent, so the park can be decided again. The attempt cap goes 25s -> 45s so a contended attempt fails cleanly as a retryable lock timeout instead of being killed mid-run, and a tap's whole background life is bounded at 3 minutes from its ack: a budget that stops the next attempt but never interrupts one in flight, so a graceful drain stays bounded. TestBurstResolvesEveryTap replays the burst against a fake gate that models the single-writer lock, with another process holding it longer than an invocation will wait: all six taps resolve exactly once and no two gate invocations overlap. Reverting either mechanism fails it with six lock-timeout cards — the recorded outcome. Residual in FOLLOWUPS.md: a lock held longer than the budget still ends on the operator; the durable answer is the accept-before-ack log the hard-crash entry already needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pinned to |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@claude review |
|
Claude finished @itsHabib's task in 4m 5s —— View job Claude's Review
Reviewed at Retry policy safety ✅The guard in func busy(out []byte, code int) error {
if decided(code) || !stateBusy(out) {
return nil
}
return ...
}
One thing worth noting: Queue replacing per-escalation lock ✅ with one comment-accuracy noteThe single-slot design is strictly stronger than the per-escalation The Minor comment inaccuracy ( 45s attempt cap / 3-min budget / graceful drainresolveTimeout = 45s covers two of gate's 10s state-lock waits (grant lookup + resolve) with 25s headroom, so a contended attempt fails cleanly as a retryable Graceful drain bound: the claim that drain is "bounded by roughly one window plus a final attempt" ( One timing issue worth naming explicitly (P2): the Only 3 of the 4 advertised attempts execute when every attempt burns the full Silent drop of budget-expired error (P2): in if werr := wait(ctx, s.backoffFor(attempt)); werr != nil {
return code, out, err // werr (context.DeadlineExceeded) is discarded
}When the 3-minute budget expires during a backoff, Test coverage
One gap: there's no end-to-end SummaryThe core fix is sound. The retry policy safety is airtight —
FIFO comment ( |
|
Claude finished @itsHabib's task in 5m 35s —— View job Review — PR #283
|
| Area | Finding |
|---|---|
| Retry policy safety | Sound. decided() guard first; execRunner contract confirmed; test coverage complete. |
| Queue correctness | Correct mutual-exclusion. One comment inaccuracy: FIFO ordering is not Go-spec-guaranteed. |
| Cap/budget/drain | Interactions are correct. Wait() bound is O(1) in queue depth. Budget-expires-mid-backoff path silently drops werr but produces honest card. |
The FIFO comment is the only change I'd make before merging. Everything else is well-reasoned, well-tested, and the FOLLOWUPS entry correctly records the remaining residual.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9cf3235d9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func busy(out []byte, code int) error { | ||
| if decided(code) || !stateBusy(out) { | ||
| return nil | ||
| } | ||
| return fmt.Errorf("%w: gate exited %d without taking its state lock, so nothing was recorded", ErrStateBusy, code) |
There was a problem hiding this comment.
Distinguish pre-append lock failures before retrying
When another writer acquires the state lock after applyJudgment has appended the terminal action but before stampResolution runs, Gate exits 4 with state_lock_timeout even though the authoritative decision already landed. Classifying solely by exit code and this substring retries that partially completed resolve; the retry then finds the park absent and reports “Already resolved,” leaving the required resolution provenance unstamped and concealing the failure. Retry only a lock timeout known to precede all appends, or make Gate resume the missing stamp.
AGENTS.md reference: cmd/escalate/AGENTS.md:L19-L23
Useful? React with 👍 / 👎.
| func (s *Server) runQueued(ctx context.Context, cb callback) (int, callback, error) { | ||
| release, err := s.queue.enter(ctx, s.notice, func() { s.status(cb, queuedText(cb)) }) |
There was a problem hiding this comment.
Process grant callbacks before their signatures expire
Because this single queue also holds T0 grant callbacks, Gate may not receive the original signed body until after a long-running resolve or retry sequence. Escalate can accept a callback whose timestamp is still within the five-minute window, then Gate's required independent authentication can reject the same timestamp after the queue wait; for example, a callback arriving four minutes old fails after one minute queued even though the tap was already acknowledged and its buttons removed. The grant path needs to preserve Gate's immediate verification or otherwise avoid queueing beyond the remaining signature lifetime.
AGENTS.md reference: cmd/escalate/AGENTS.md:L32-L35
Useful? React with 👍 / 👎.
|
Reviewer: claude |
…outlive a signature Review round 1. Two verified findings from the panel, one comment nit. A resolve is several appends — judgment, verdict, action, then the resolution stamp — each taking gate's lock separately, so `state_lock_timeout` did not by itself mean "recorded nothing": a lock lost between appends, or on the stamp, leaves the decision in the log, and the retry then found the park closed and reported a benign "already resolved" over a missing stamp. gate already answers that question itself — judgeSlotState re-reads the run and says "the one judgment is unspent and a retry is legal" only for a failure before any append. resolveBusy now requires those words as well as the lock timeout. A grant callback keeps the plain test: its whole effect is one single-use append gate excludes atomically, so a lost lock wrote nothing and a retry that raced a winner is answered "already resolved", never applied twice. A grant callback also gets its own budget: the life left on the Slack signature gate re-verifies, less one attempt. Queued behind a resolve's retries it could otherwise be forwarded outside Slack's ±5-min window and refused for being late — a decision turned into a confusing refusal. A tap whose budget is spent still gets its attempt whenever the queue is free, because gate, not serve, is the authority on whether a signature is still good; taking a free slot no longer consults the clock at all. Also: the queue's comment claimed a FIFO wake order Go does not guarantee (the guarantee is mutual exclusion, not fairness), and two comments still described the drain bound as the old per-resolve timeout. TestLockLostAfterTheDecisionLandedIsNotRetried drives the case Codex named end to end; the burst fixture now carries gate's real annotated message, both halves of which serve classifies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 fixes pushed as Codex P2 — pre-append lock failures. Verified and fixed. The grant path keeps the plain lock test, deliberately: its whole effect is one single-use append gate excludes atomically ( Codex P2 — signatures expiring behind the queue. Verified and fixed. A grant callback's budget is now the life left on its own signed timestamp minus one attempt, capped by the ordinary budget, so it can never be forwarded outside the ±5-min window gate re-verifies. A tap whose budget is already spent still gets its attempt whenever the queue is free — Claude — FIFO claim. Dropped; the comment now says waiters are woken roughly in arrival order and that nothing depends on it. Also fixed two comments ( @codex review |
|
@claude review |
|
Claude finished @itsHabib's task in 4m 2s —— View job Review — Round 1 fixes (
|
| Case | Age | Expected budget |
|---|---|---|
| Park | 4 min | resolveBudget (ordinary budget, unaffected) |
| Fresh grant | 0 min | resolveBudget (5min window - 45s headroom > 3min, so clamped) |
| Aging grant | 4 min | maxSkew - 4min - resolveTimeout = 15s |
| Stale grant | 6 min | max(0, -1min - 45s) = 0 |
| Unparseable | — | 0 |
All five cases are correct.
The fast-path in enter() (queue.go:152-156) is important and correct:
select {
case q.slot <- struct{}{}:
return func() { <-q.slot }, nil
default:
}An idle ingress takes the slot before the budget is consulted, so a stale-budget grant callback still gets its one attempt when the queue is free — consistent with the comment that gate, not serve, is the authority on whether a signature is still good. A tap with budget=0 that must WAIT exits immediately via the cancelled context. Both behaviors are correct.
3. FIFO comment ✅
Fixed correctly: "Waiters are woken roughly in arrival order, but nothing here depends on that: the guarantee is mutual exclusion, not fairness." Clean and accurate.
Unaddressed: P2 from previous review — silent werr drop in attempts()
This was called out in the previous review and is still present (serve.go:352-354):
if werr := wait(ctx, s.backoffFor(attempt)); werr != nil {
return code, out, err // werr (context.DeadlineExceeded) is discarded
}When the budget expires during a backoff, werr is dropped and the last ErrStateBusy is returned. The card is correct — "NOT recorded, retry legal" — but the serve log shows the lock-busy message with no indication that the budget was the stop condition rather than exhausted retries. For a 6-tap burst where the trailing taps time out in queue or backoff, both outcomes look identical in the log.
P3 nit — runQueued log message hardcodes resolveBudget
serve.go:331: fmt.Errorf("%w: no turn within %s (%v)", ErrStateBusy, resolveBudget, err) — for a grant callback whose budget was e.g. 15s, the log says "no turn within 3m0s". The (context deadline exceeded) suffix signals something expired, but the stated duration is wrong. Not a correctness issue and the context is self-evident; mentioning for completeness.
Summary
| Fix | Finding |
|---|---|
resolveBusy / pre-append guard |
✅ Correct. Two-part test is tight; fail-safe direction; tests pin both the policy and the end-to-end path. |
| Grant callback budget | ✅ Correct. Math verified, fast-path design is right. |
| FIFO comment | ✅ Fixed. |
werr drop in attempts() |
|
runQueued log message |
P3 nit — wrong duration displayed for short-budget grant callbacks. |
The two Codex P2s are fully addressed and the implementation is correct. The werr P2 is the only outstanding item before this is clean.
|
Reviewer: claude |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ad3ae88d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if err == nil || !bytes.Contains(out, []byte(gateRetryLegal)) { | ||
| return nil |
There was a problem hiding this comment.
Retry lock failures before applyJudgment
When another process acquires the state lock after findGrant succeeds but before gate resolve finishes runOfEscalation or escalationIsOpen, Gate returns that state_lock_timeout directly from cmdResolve (lines 1787–1800) without passing through judgeSlotState (which is only called for applyJudgment failures at lines 1804–1806). Nothing has been appended, but the output therefore lacks gateRetryLegal; this branch returns nil, so attempts does not retry and the already-acked tap loses an otherwise legal decision. Ensure these pre-applyJudgment lock failures receive the unspent annotation or are otherwise classified as retryable.
AGENTS.md reference: cmd/escalate/AGENTS.md:L57-L64
Useful? React with 👍 / 👎.
… had Review round 2, both operator-facing log accuracy. A budget that ran out mid-backoff and a tap that used all four attempts produced the same log line, so the one fact the log was there to carry — why this tap stopped — was the one it dropped. The wait error is now wrapped into the returned error with the attempt count. ErrStateBusy stays in the chain, so the card is unchanged. And a queue wait that timed out always named resolveBudget, even for a grant callback whose budget is the life left on its signature: a log naming a three-minute wait for a tap that waited fifteen seconds sends the operator looking for something that never happened. runQueued is handed the tap's own budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 (final fix-round) pushed as
That is the second fix-round, so per the repo's review-cycle discipline I stop here and take anything further to the judge. Panel state at this head: Codex completed clean on @codex review |
|
@claude review |
|
Claude finished @itsHabib's task in 2m 17s —— View job Review — Round 2 fixes (
|
| Fix | Finding |
|---|---|
werr wrapping in attempts() |
✅ Correct. ErrStateBusy stays in chain; attempt count + context error in log. |
runQueued budget duration |
✅ Correct. Tap's actual budget duration logged, not the package default. |
Codex P2 (pre-applyJudgment retryable lock) |
Open — within round-2 discipline to defer to judge. |
Both round-2 fixes are correct. The PR is ready for judge review.
|
Reviewer: claude |
|
Recording a residual the last review surfaced, verified but deliberately not fixed here — the two-fix-round cap is met. A lock lost in the pre-append READ window is no longer retried. Three things bound that:
That gate change plus a FOLLOWUPS entry belong in a follow-up PR, not a third round on this head: another push would invalidate the panel attestations and spend a fourth review cycle against a three-cycle ceiling. Carrying it to the judge as a recorded deferral. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c167ffa4b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func resolveBusy(out []byte, code int) error { | ||
| err := busy(out, code) | ||
| if err == nil || !bytes.Contains(out, []byte(gateRetryLegal)) { | ||
| return nil |
There was a problem hiding this comment.
Mark pre-judgment resolve lock failures as retryable
Fresh evidence at the final head is that resolveBusy still rejects every lock timeout lacking gateRetryLegal, while gate resolve can return state_lock_timeout directly from runOfEscalation or escalationIsOpen before applyJudgment (cmd/gate/main.go:1787-1800); those reads take the state lock, and neither branch passes through judgeSlotState. If the lock is acquired after the preceding gate next succeeds, nothing has been appended, but attempts stops after this first code-4 result and the already-acked tap loses a legally retryable decision. The new nextError handling covers only the separate gate next subprocess, so these pre-judgment Gate branches still need the unspent annotation or another safe classification.
AGENTS.md reference: cmd/escalate/AGENTS.md:L57-L64
Useful? React with 👍 / 👎.
|
Ready for the gate flow, blocked only on grant custody.
One deferral is recorded above: a lock lost in |
The judge blocked the head on the gap it named: a resolve can lose gate's state lock in the two reads that PRECEDE any append — resolving the escalation to its run, and checking the park is still open, both of which take the lock through Store.List — and those errors returned bare. serve's retry keys on gate saying a retry is legal, so it declined to retry a class that was in fact safe to retry, and no test covered the path. gate now answers there too. preAppendFailure annotates a lock failure from those reads with the fact that is structural at that point: this invocation appended nothing, so nothing was recorded and a retry is legal. It claims no more than that — an EARLIER invocation may have spent the run's one judgment, and gate's replay guard, not this sentence, refuses that retry. Only a lock failure is annotated, so a mistyped id is not made retryable by annotation. serve's allowlist is the phrase both annotations share, so it now covers every pre-append path and still covers none of the spent ones: a recorded judgment reads "a retry only returns judgment_duplicate" or "a retry resumes that judgment", and a lock lost stamping the resolution carries no annotation at all. Also corrects the post-append fixture, which invented a "stamp resolution:" prefix gate does not emit — Append returns the store's error bare, which is exactly why that shape has no annotation to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The judge blocked Verified first. Fixed in gate, in serve's rule now keys on the phrase both annotations share, so it covers every pre-append path and still covers none of the spent ones: a recorded judgment reads Tests: @codex review |
|
@claude review |
|
Claude finished @itsHabib's task in 3m 7s —— View job Review —
|
| Area | Finding |
|---|---|
preAppendFailure placement |
✅ Correct — wraps exactly the two pre-append reads; stamp path is untouched |
| Annotation semantics | ✅ Does not overclaim; judgment unspent-ness left to judgeSlotState |
gateRetryLegal narrowing |
✅ Covers both annotation sources; excludes all post-decision paths |
gateLockAfterAppendJSON correction |
✅ Important fix — tests now against the real message shape |
TestPreAppendFailureNamesWhatWasNotRecorded |
✅ Pins all directions including the "not unspent" negative |
| Classification table coverage | ✅ New row closes the pre-append read gap |
The open Codex P2 is fully addressed, and the implementation is correct, well-documented, and well-tested. No further findings.
|
Reviewer: claude |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 87ae220bc1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func (s *Server) runQueued(ctx context.Context, budget time.Duration, cb callback) (int, callback, error) { | ||
| release, err := s.queue.enter(ctx, s.notice, func() { s.status(cb, queuedText(cb)) }) | ||
| if err != nil { | ||
| // The tap's own budget, not the package default: a grant callback's is the | ||
| // life left on its signature, and a log naming the wrong window would send | ||
| // the operator looking for a wait that never happened. | ||
| return 0, cb, fmt.Errorf("%w: no turn within %s (%v)", ErrStateBusy, budget, err) |
There was a problem hiding this comment.
Preserve queued T0 callbacks instead of expiring them
Fresh evidence at this head is that every callback now shares this slot and waits using the grant callback's signature-derived context. If a park resolution holds the slot through its retries and backoffs for three minutes, even a freshly accepted T0 callback reaches this branch and returns ErrStateBusy without ever invoking GrantTap; ServeHTTP has already acknowledged the tap and removed its buttons, so the operator's signed approve/deny is lost even if Gate's lock subsequently frees. Give grant callbacks priority or an immediate verification path, or durably preserve them for replay instead of expiring them in the shared queue.
AGENTS.md reference: cmd/escalate/AGENTS.md:L57-L64
Useful? React with 👍 / 👎.
Second judgment, second real defect: the queue could eat a T0 grant callback. It carries a Slack signature gate re-verifies on arrival, so every second queued behind a retrying resolve is authority draining away — and a queue deep enough to outlast the signature consumed the operator's tap and applied nothing, with the card already acked and its buttons gone. Bounding the wait by the signature's remaining life avoided forwarding a dead signature but still dropped the decision. Grant callbacks are no longer queued. They keep the immediate forward they had before the queue existed, which removes the mechanism entirely rather than bounding it. Nothing about their safety needed the queue: one single-use append that gate excludes atomically, and gate re-authenticates the callback itself. The signature-derived budget stays, now bounding retries alone. The queue keeps doing what it was built for: stopping a burst of park resolutions from contending with each other for gate's single state lock. TestGrantCallbackIsNotQueuedBehindResolves holds the slot with a park resolve, then asserts the grant tap still reaches gate. Its first version passed with the callback queued — a race, since nothing guaranteed the park had taken the slot — so it now waits for the slot to be held before firing, and fails as it should when the callback is queued again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Second judgment addressed in The defect. A T0 grant callback carries a Slack signature gate re-verifies on arrival. Queued behind a retrying park resolve, its budget could run out before its turn, and The fix removes the mechanism rather than bounding it. Grant callbacks are no longer queued at all; they keep the immediate forward they had before this PR. Nothing about their safety needed the queue: the effect is one single-use append gate excludes atomically, and gate re-authenticates the callback independently. The signature-derived budget stays and now bounds retries alone. The queue keeps doing what it was built for, which is stopping a burst of park resolutions from contending with each other. On the park side there is no equivalent new loss path. Before this PR a park tap that hit the lock was reported failed after one attempt with the same acked-and-gone card. It now gets four attempts across ~90s and a card that stays honest while it waits, so that path is strictly improved, not newly lossy. The residual — a lock held longer than the whole budget — is the one recorded in FOLLOWUPS, and its durable answer is the accept-before-ack log the hard-crash entry already needs. Test. @codex review |
|
@claude review |
|
Claude finished @itsHabib's task in 2m 35s —— View job Review —
|
| Area | Finding |
|---|---|
Grant callback bypass in runCallback |
✅ Correct on both axes: signature drains while queued, no serialization needed |
Test synchronization via <-holding |
✅ Pins the invariant properly; first-version race is closed |
wantGrant=true for lock-after-append |
✅ Intentional; gate's atomicity makes post-append grant retries safe |
| Budget comment / doc updates | ✅ Accurate throughout |
No findings. The PR is ready for the judge.
|
Reviewer: claude |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6701199146
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func resolveBusy(out []byte, code int) error { | ||
| err := busy(out, code) | ||
| if err == nil || !bytes.Contains(out, []byte(gateRetryLegal)) { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Annotate all pre-append lock failures as retryable
Fresh evidence beyond the addressed runOfEscalation/escalationIsOpen reads is that applyJudgment can still lose the state lock before its first append, notably in its initial e.st.Run or capability lookup. If the lock remains held, judgeSlotState's immediate diagnostic reread also times out, so Gate's output contains state_lock_timeout but not a retry is legal; this branch then returns nil, attempts stops after one try, and the already-acked park decision is not applied despite being safely retryable. Have Gate structurally annotate these known pre-append failures rather than depending on a second lock-taking read.
AGENTS.md reference: cmd/escalate/AGENTS.md:L57-L64
Useful? React with 👍 / 👎.
|
Third judgment recorded, and this one rests on a false premise. Stopping here: three gate cycles are spent, which is the grant's ceiling. The judgment holds because "The supplied diff omits cmd/gate/main.go, so those producer paths cannot be verified." That is checkable and wrong:
So the producer paths the judgment asks to see are in the evidence it was given. Both of its earlier objections were real and are fixed; this one is a provider misread, not a defect. Also worth naming for whoever picks this up: the park is driven every cycle by State of the head: panel complete and clean on |
Summary
Six Slack taps inside ~10s on 2026-09-05 produced one resolve and five failures: four
state_lock_timeout after 10s, one killed at the 25s attempt cap after waiting on gate's lock twice (thegate nextlookup, then the resolve).serveran every callback in its own goroutine, so the taps contended for gate's single-writer state lock with each other. Every failure line said the judgment was unspent and a retry legal — and nothing retried, while the card told the operator the decision had failed.What this adds
Two mechanisms, both inside
serve. Gate's lock semantics and its 10s timeout are untouched, and no authorization step moves (403-before-lookup for a non-allowlisted user is unchanged).resolveQueue,cmd/escalate/internal/serve/queue.go). Every background callback passes through a single slot, so this process never runs two gate invocations against one state dir at once. It subsumes the per-escalation lock it replaces: same-escalation taps are still serialized (TestServeHTTPSerializesSameEscalationstill passes), and now different escalations are too.state_lock_timeoutis the one failure gate takes before any append, so it recorded nothing.servenames itErrStateBusy— read off gate's own output, never imported, on both the resolve and thegate nextgrant lookup — and retries four times over ~90s, riding out a lock another process holds. Every landed decision (exit 0..3) and every other failure is reported on the first try; retrying one of those could double-apply.The card stays honest throughout: queued while waiting a turn, retrying while riding out the lock, and "NOT recorded" only once the retries are spent (naming that nothing was spent, so the park can be decided again).
Two constants move with reasons: the attempt cap 25s → 45s, so a contended attempt fails cleanly as a retryable lock timeout instead of being killed mid-run (that is the exit -1 in the log); and a new 3-minute budget per tap, measured from its ack, which stops the next attempt but never interrupts one in flight — so a graceful drain stays bounded.
Validation
TestBurstResolvesEveryTapreplays the burst against a fake gate that models the single-writer lock, with another process holding it longer than an invocation will wait. All six taps resolve exactly once; no two gate invocations overlap.TestBurstCardStaysHonestpins the queued/retrying vocabulary and that no resolved tap is reported failed.TestBurstGivesUpAfterAttemptspins the bound: four attempts, nothing recorded, an honest card.TestBusyClassificationpins the retry policy, including that a landed decision is never retried even if its output quotes the lock error.processreverted to the old direct call, the burst tests fail with six lock-timeout cards — the recorded 2026-09-05 outcome.gofmt,go vet ./...,golangci-lint run ./cmd/escalate/...(0 issues),go test ./...,go test -race ./cmd/escalate/...all green.Residual, recorded in
FOLLOWUPS.md: a lock held longer than the budget (a ten-minutegate gaterun) still ends with a failed card and a CLI resolve. The durable answer is the accept-before-ack log the hard-crash entry already needs.🤖 Generated with Claude Code