Skip to content

fix(runtime): keep a stalled queue delivery from running after a newer turn - #124

Merged
ThomasK33 merged 3 commits into
mainfrom
fix/queue-prelude-stall
Oct 29, 2026
Merged

ThomasK33 merged 3 commits into
mainfrom
fix/queue-prelude-stall

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Why

Under ConcurrencyQueue, the bot must never answer an older message after a newer one (ADR 0015 §3). A queue delivery that stalled in its prelude (in its lock call, routing, or dedupe) could still wait for the lock, or take it, after a newer delivery had taken the thread's turn. The stalled delivery then ran last (#123). #122 covered a waiter that was already parked. Nothing recorded that a newer delivery had taken its turn.

What changed

  • Each lock scope keeps a queue turn record (queueTurn) while any of its deliveries is in flight. active counts those deliveries, and won holds the admission sequence of the newest one that took a turn. leaveQueueScope drops the record when the last delivery reaches its outcome.
  • takeQueueTurn replaces supersedeOlderWaiter. A lock taker older than won releases the lock and ends as superseded. Otherwise it sets won and supersedes an older waiter, as before.
  • registerPending refuses a queue delivery older than won.
  • commitPending replaces the isPending recheck and the clearPending call after a waiter acquires the lock. It is one queueMu section, and it sets won for the waiter.
  • enterQueueScope counts a queue delivery in its scope's turn record once its lock scope is known, before any State call. The admission sequence stays in dispatch, as on main.
  • The ConcurrencyQueue GoDoc states the rule and the case that remains.

Scope

  • One runtime instance, as ADR 0015 decides.
  • Debounce keeps its documented stall behavior, because it creates no turn records. Its only change is the atomic commitPending: a waiter that passes the recheck can no longer be logged as superseded and then run.
  • Drop, burst, and concurrent do not change.
  • An independent review found three latent gaps in the turn record that no normal path reaches. They are tracked in runtime: harden the queue turn record (idempotent release, State panics, retention test) #129.

Tradeoffs

  • The turn record counts a delivery only from the point where its lock scope is known. Only two steps run before that: the Observer Dispatch callback, which must not block by its contract, and the adapter's ValidateThreadID, which takes no context. Every State call runs after it. A delivery delayed there until every newer delivery for the scope has finished still runs afterwards, as on main. The GoDoc states this.
  • The first version (1a53411) moved the admission sequence to lock-scope entry. Review found that a debounce delivery delayed in validation then displaced a newer waiter. d7a7ccb keeps the sequence in dispatch for every strategy.
  • I rejected tracking deliveries before their scope is known. It needs a global set of unscoped deliveries, and records that outlive their scope during a stall, for work that does not block by contract.
  • The turn record is one small struct per scope with in-flight work. Memory grows with in-flight deliveries, not with the number of scopes.

Blast Radius

Only the queue strategy changes, and only when a delivery stalls in its prelude long enough for a newer delivery to take the turn. That older delivery now ends as superseded (ignored outcome, debug log chat queue waiter superseded) instead of running last. Five new assert calls guard the record's invariants, and they panic in production if broken.

Verification

  • TestQueueDeliveryStalledBeforeRegisteringNeverRunsAfterNewerLockTaker lands first (2268e7a). It fails with handled = [A C X], want [A C] in deferred and sync dispatch, 3 of 3 runs.
  • Two more tests pin a stall inside the lock call and a newer waiter that takes the turn. All three pass 5 of 5 runs under -race.
  • TestDebounceDeliveryDelayedInValidationCannotDisplaceNewerWaiter fails on 1a53411 with handled = [older] and passes on main and on d7a7ccb.
  • I broke each guard in turn: the won checks in registerPending and takeQueueTurn, the won update in each commit path, and the record lifetime. Each break fails a test or trips an assert.
  • go test -race ./... and go vet in every workspace module pass.
  • A TLA+ model of this coordination (a local skill, not in this repo yet) has a new property: no queue delivery runs after a newer turn. It fails on main along both stall paths. On d7a7ccb it passes with up to 4 deliveries (7.5 million states), under one stated assumption: a newer delivery never finishes while an older one is still in the pre-scope work. Without that assumption, a separate check fails along the documented residual path, as expected.

Fixes #123


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

…er turn (#123)

X hits the Lock Conflict and stalls in its dedupe mark. A finishes, C takes
the free lock and runs, and then X registers into the empty waiter slot,
waits for the lock, and runs last: handled = [A C X], want [A C].

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…r turn

A queue delivery that stalled in its prelude (lock call, routing, dedupe)
could register or take the lock after a newer delivery had already taken
the scope's turn, and then run last. Nothing recorded the newer turn once
its waiter entry was gone.

Each lock scope now keeps a queue turn record while any of its deliveries
is in flight: the count of those deliveries and the admission sequence of
the newest one that took a turn. registerPending and the prelude lock
taker resolve a delivery older than that turn as superseded. A waiter takes
its turn in commitPending, which joins the isPending recheck and the clear
into one queueMu section.

The admission sequence is now assigned when a delivery reaches its lock
scope, in the same section that counts it, so no turn can be taken between
the two. Only non-blocking work (the Observer callback and thread ID
validation) runs before that point.

Two more tests pin the lock-call stall and the newer-waiter case.

Fixes #123

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T18:24:31.591567Z d7a7ccb Manual request
🔒 Security Review ✅ Completed 2026-10-29T18:26:09.525395Z d7a7ccb Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 1a534116a5

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a534116a5

ℹ️ 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".

Comment thread runtime.go Outdated
1a53411 moved the admission sequence to lock-scope entry. Under debounce,
an older delivery delayed in thread ID validation then got a larger
sequence than a newer waiter and displaced it, so debounce handled the
stale event. On main the older delivery is refused.

dispatch assigns the sequence again, as on main. enterQueueScope only
counts a queue delivery in its scope's turn record. A queue delivery
delayed before its scope is known now ends stale while a newer turn is
still in flight. The GoDoc states the case that remains: a delivery
delayed there until every newer delivery for the scope finished still
runs afterwards, as on main.

TestDebounceDeliveryDelayedInValidationCannotDisplaceNewerWaiter fails
on 1a53411 with handled = [older] and passes here.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: d7a7ccb118

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: d7a7ccb118

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33

Copy link
Copy Markdown
Member Author

Readiness record

Decision: ready to merge, with follow-ups tracked in #129. Not merged: the owner merges.

  • Final commit: d7a7ccb.
  • CI on that commit: test (pinned) and test (minimum) pass.
  • Local checks on that commit (Go 1.27.1): gofmt clean, go vet passes, go test -race -count=1 ./... passes, and the three queue stall tests and the new debounce test pass 5 of 5 runs under -race.
  • Reviews: 4 rounds (I count each completed normal or security review as one).
    1. Normal review on 1a53411: one P2. Moving the admission sequence to lock-scope entry let a debounce delivery delayed in ValidateThreadID displace a newer waiter. TestDebounceDeliveryDelayedInValidationCannotDisplaceNewerWaiter confirmed it (3 of 3 failures on 1a53411, 3 of 3 passes with main's runtime.go). d7a7ccb keeps the sequence in dispatch for every strategy. Thread resolved.
    2. Security review on 1a53411: no issues.
    3. Normal review on d7a7ccb: no major issues.
    4. Security review on d7a7ccb: no issues.
  • Threads: 1 in total, 0 unresolved (re-checked 7 minutes after the last verdict).
  • Guard checks: I broke each new guard in turn on d7a7ccb, and each break failed a test or tripped an assert.
  • Model: the TLA+ model of this coordination passes every check on d7a7ccb, with up to 4 deliveries, under the stated pre-scope assumption. The documented residual fails its own check, as expected.
  • Independent advisor (fresh context, given the goal, diff, findings and test results): recommends ready. It traced that every queue delivery leaves its scope exactly once in both dispatch modes, and that the new asserts cannot fire on a normal path. It reran the suite and the new tests.
  • Follow-ups (runtime: harden the queue turn record (idempotent release, State panics, retention test) #129): make the deferred release wrapper idempotent, handle a panicking State call that leaves a stale waiter, add a retention test for the turn record, and document that ValidateThreadID must not block. No normal path reaches the first two today. Owner: maintainer triage. Trigger: the next queue coordination change, or before queue is called production-grade.
  • Why I stopped: every gate holds on the final commit, and the remaining findings are latent and tracked.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

@ThomasK33
ThomasK33 merged commit 7bc1cd0 into main Oct 29, 2026
2 checks passed
ThomasK33 added a commit that referenced this pull request Oct 29, 2026
…nd a State panic (#134)

## Why

Every open follow-up issue in coder/chat gets one small, reviewed PR, and this one closes #129. An independent review of #124 found three gaps in the queue turn record. A second call to a deferred delivery's release, or a panic from `State.AcquireLock` while a queue waiter polls, can trip the `leaveQueueScope` assert, which panics in production. No test read the record, so a missing `delete` passed every Go test.

## What changed

- `dispatch` now wraps a deferred delivery's release in `sync.OnceFunc`, like the release that `admit` returns. A second call no longer leaves the queue scope again.
- `queueForLock` clears its waiter's pending entry in one `defer`, in place of the two explicit `clearPending` calls. A panic from `State` no longer leaves a stale waiter behind the turn record.
- Three new tests pin the record. The `Adapter.ValidateThreadID` GoDoc now says that it must not block, the rule that the `ConcurrencyQueue` GoDoc relies on.

## Scope

- The State-panic case is a `DispatchSync` case, because `net/http` recovers a handler panic. A panic in a deferred tail goroutine still ends the process, as on `main`.
- Debounce shares `queueForLock` and gets the same cleanup. Its outcomes do not change.

## Tradeoffs

- The issue offered two places to clear the waiter's entry. I picked the `defer` in `queueForLock`, because that function already ends the registration on every other exit, and the panic exit was the one it missed. A clear in `leaveQueueScope` would need the delivery's event or admission sequence in `queueEntry`, and the assert there could then never catch a leaked registration. With the new `defer` removed, the existing queue tests trip that assert.
- No public path calls the release twice, so the test reaches it through a new `export_test.go` hook. For that, `dispatch` builds the release in a new method, `deferredRelease`.

## Blast Radius

Only queue and debounce waiters change. Each waiter takes one more `queueMu` section when its lock wait ends. An abandoned or failed waiter now clears its entry just after its log line, not just before it.

## Verification

- Each fix lands after its failing test. Before the fix, `TestQueueDeferredReleaseCalledTwiceLeavesItsScopeOnce` fails with `turn records = 0 after one of two deliveries released twice, want 1`. `TestQueueStatePanicWhileWaiterPollsLeavesNoStaleWaiter` fails because the waiter panics with the assert instead of the State's panic, and a later duplicate trips the same assert.
- With the `delete` in `leaveQueueScope` removed, `TestQueueIdleScopeRetainsNoTurnRecord` fails in both dispatch modes.
- The three new tests pass 20 of 20 runs under `-race`. `go vet` and `go test` pass in every workspace module, and `go test -race` passes for the root package.

Closes #129

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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.

bug(runtime): ConcurrencyQueue runs an older delivery that stalled in its prelude after a newer lock taker

1 participant