Skip to content

fix(tui): reconcile queued cards after missed delivery echoes - #58072

Open
SKar-2007 wants to merge 1 commit into
anomalyco:devfrom
SKar-2007:inbox-stuck-pending
Open

SKar-2007 wants to merge 1 commit into
anomalyco:devfrom
SKar-2007:inbox-stuck-pending

Conversation

@SKar-2007

@SKar-2007 SKar-2007 commented Oct 22, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #56748

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A queued prompt that got delivered while the event stream was down stayed rendered above the prompt input until you restarted the TUI; clicking it failed with a 409 because the server-side inbox row was already consumed. The local row is pinned by the outbox guard, and only its inbox echo, a positive inbox read, or a rollback ever cleared it — but an empty inbox snapshot is also what a lagging projection looks like (that's a pinned invariant, see "keeps the row when the response lands before the echo" in packages/tui/test/cli/tui/data.test.tsx), so absence alone can never prove the row consumed.

So instead of dropping the guard on timing, the fix drops it on evidence:

  • message.sync removes the matching pending row once the fetched messages contain its id. The server writes that message in the same transaction that consumes the inbox row, so a fetched message is proof of delivery — and the TUI already re-reads messages when the event stream reconnects, which is exactly when the stale card should clear.
  • A new data.session.pending.release() unpins a row without removing it. On an inbox-mutation ConflictError the session route releases the guard and re-reads the inbox, so a consumed row clears while one that already has the requested delivery (a re-click after a lost delivery-change echo) stays. data.session.pending.discard() removes the row outright after a successful cancel, which is authoritative even when the row was already gone.
  • removePending clears the outbox entry too, so every removal path fully unpins.

One thing worth passing on: my first attempt was the straightforward version — clear the guard when the prompt POST settles, which is what the closed #57427 did — and it fails the lag test named above. Reads really can lag that response, so settlement cannot authorize dropping the row; positive evidence can.

How did you verify your code works?

Three regression tests in packages/client/test/solid-inbox-race.test.ts: one walks prompt → both echoes lost → empty inbox read (row correctly kept) → message listing containing the delivered message (row drops); one pins release letting a read keep a live conflicted row and drop a consumed one; one pins discard after a successful cancel.

  • packages/client: 232 pass, 0 fail
  • packages/tui: 1537 pass, 1 pre-existing failure unrelated to this change (go-upsell-art "the art stops every frame" also fails on clean dev, verified by stashing); test/cli/tui/data.test.tsx 50 pass
  • Root bun run check: 34/34 green
  • The changed test file rerun 10x: stable

Screenshots / recordings

Not a UI change — the change removes a stale card; the reporter's before screenshots are already in the issue.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #56735

Public API: adds data.session.pending.discard() and data.session.pending.release() to the @opencode/client Solid data layer (backwards compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fixes the stale queued card from #56748. I ran the issue's drive script (event stream dropped while the prompt is sent, simulated model). On the base, the delivered prompt stays a grey pending card after idle. With this PR it turns into a normal delivered message. The client tests, the TUI data.test.tsx and the type checks of both packages pass. Using a fetched message as proof of delivery is sound: on the server, a message row exists only after its inbox row was consumed, under the same ID.

Public API: adds data.session.pending.discard(sessionID, inboxID) to the Solid data layer in @opencode/client (backwards compatible). The server API, events and SDK types are unchanged.

One concern, in the inline comment: a 409 from steer/queue doesn't always mean the row is gone, so discarding on every ConflictError can hide a prompt that is still queued.

Related: #56735

Before

After

// The server rejects an update only when the row is already gone —
// delivered or cancelled while its echo was lost — which a stale queued
// card still renders. Drop it instead of leaving it until a restart.
if (typeof error === "object" && error !== null && "_tag" in error && error._tag === "ConflictError") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A 409 here doesn't always mean the row is gone. projectDelivery in packages/core/src/session/inbox.ts only updates WHERE delivery = from, so steering a row that is already steer (or queueing one that is already queue) also returns InboxConflictError → ConflictError while the row still exists. That can happen in the same stream-drop case this PR targets: a delivery-change echo is lost, the card still shows the old mode, and the user clicks again. The card then disappears even though the prompt is still queued, until a later inbox re-read brings it back.

Could the conflict path drop the outbox guard and re-read the inbox (pending.sync), instead of removing the row outright? A consumed row then clears, and a live one stays.

A prompt admitted optimistically is pinned by the outbox guard until its inbox echo, a positive inbox read, or a rollback clears it. When the event stream drops across both the enqueued and delivered echoes, no inbox read can prove the row consumed — an empty snapshot is also what a lagging projection looks like — so the queued card outlived its turn until restart, and clicking it hit a 409.\n\nDrop the guard from evidence instead. A fetched message proves delivery: the projection writes the message in the same transaction that consumes the inbox row, so message.sync removes the matching pending row. A lifecycle conflict proves the row is not in the expected state; the session route releases the guard and re-reads the inbox, so a consumed row clears while one that already has the requested delivery stays. A successful cancel is authoritative on its own and discards the row; removePending clears the outbox entry for every removal path.\n\nClearing the guard when the prompt POST settles, as in anomalyco#57427, breaks the pinned invariant that reads may lag that response (test/cli/tui/data.test.tsx 'keeps the row when the response lands before the echo').
@SKar-2007
SKar-2007 force-pushed the inbox-stuck-pending branch from 4a9c350 to 71c354b Compare October 22, 2026 10:23
@SKar-2007

Copy link
Copy Markdown
Author

Good catch on the 409 path — you're right that projectDelivery updates WHERE delivery = from, so a re-click after a lost delivery-change echo conflicts while the row is still queued. Reworked per the suggestion: ConflictError now releases the outbox guard without removing the row (data.session.pending.release(inboxID)) and re-reads the inbox, so a consumed row clears and a live one stays (with its real delivery mode). discard is now only used on the authoritative cancel success (204 — a no-op for a missing row), and removePending still unpins every removal path.

Added release lets a fresh read settle a conflicted row to solid-inbox-race.test.ts: the live-row read keeps the card, a later empty read drops it.

Re-verified: client suite 232 pass, TUI data.test.tsx 50 pass, root check 34/34, full TUI suite 1537 pass with only the pre-existing go-upsell-art failure (--rerun-each 10 on the touched file is stable).

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The conflict concern is addressed. On a 409, the route now releases the outbox guard and re-reads the inbox instead of discarding the row, so a consumed row clears and a live one stays with the server's delivery mode. I reran the #56748 script on the new head and the delivered prompt no longer lingers as a pending card. The client tests, the TUI data.test.tsx and both packages' type checks pass.

Public API: adds data.session.pending.discard(sessionID, inboxID) and data.session.pending.release(inboxID) to the @opencode/client Solid data layer (backwards compatible). The server API, events and SDK types are unchanged.

No further concerns.

Before

After

This branch has not been deployed

No deployments
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.

tui: queued card for a delivered image message lingers above the prompt input

1 participant