fix: wake-turn steer delivered twice — claimSteers re-claimed the steer the pump's request already carried
The wake pump fires its turn with the steer INSIDE the request body but dequeues the queue entry only after the turn returns 200 — so the entry is still queued when the handler claims. The record doesn't carry the steer yet (write-ahead persist hasn't run), so claimSteers claimed it a SECOND time and the handler appended it: outgoing = [...body, ...claimed] held two copies of the same deterministic steer id, and the write-ahead persist saved that for the whole wake turn. The client's adoption poll rendered both -> React duplicate-key warning (steer_ctx_<chatId>). withMessageIds deduped only at onFinish, so the final record looked clean. Fix: claimSteers(chatId, incoming) — a queued steer whose id is already in the turn's incoming list is being delivered BY this very turn: finalized and resolved, never appended again.
This commit is contained in:
@@ -129,7 +129,10 @@ export async function handleChatRequest(
|
||||
// appended after the learner's answer (conversion-safe: the answer carries
|
||||
// its output). Claiming happens after beginTurn, so this turn owns the
|
||||
// registry and the write-ahead persist below makes the claim durable.
|
||||
const steers = claimSteers(chatId);
|
||||
// The incoming body is passed too: a pump-fired wake turn ALREADY CARRIES
|
||||
// its steer, so claimSteers finalizes that entry instead of appending a
|
||||
// second copy (duplicate-key bug, 2026-09-18).
|
||||
const steers = claimSteers(chatId, body.messages as UIMessage[]);
|
||||
const outgoing = (
|
||||
steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
|
||||
) as UIMessage[];
|
||||
|
||||
+19
-3
@@ -173,8 +173,19 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
* are finalized, not returned. The pump re-checks steerDelivered right
|
||||
* before firing, so a claim racing the pump converges to exactly one
|
||||
* delivery either way.
|
||||
*
|
||||
* incoming (the turn's request body — the wake pump fires turns that
|
||||
* ALREADY CARRY the steer it dequeues only after the turn returns 200, so
|
||||
* the entry is still queued here): a steer already in the incoming list is
|
||||
* being delivered by THIS VERY TURN — finalized and resolved, but never
|
||||
* appended again. Without this the write-ahead persist would hold two
|
||||
* copies of the same steer id (a live React duplicate-key warning the
|
||||
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
|
||||
*/
|
||||
export function claimSteers(chatId: string): SteerMessage[] {
|
||||
export function claimSteers(
|
||||
chatId: string,
|
||||
incoming?: ReadonlyArray<UIMessage>,
|
||||
): SteerMessage[] {
|
||||
const s = state();
|
||||
const queue = s.queues.get(chatId);
|
||||
if (!queue || queue.length === 0) return [];
|
||||
@@ -182,8 +193,13 @@ export function claimSteers(chatId: string): SteerMessage[] {
|
||||
if (!record) return []; // the handler validated the chat; defensive only
|
||||
const claimed: SteerMessage[] = [];
|
||||
for (const entry of queue) {
|
||||
if (steerDelivered(record.messages, entry.steer.id)) {
|
||||
// A wake already landed it — just finish the bookkeeping.
|
||||
if (
|
||||
steerDelivered(record.messages, entry.steer.id) ||
|
||||
incoming?.some((m) => m.id === entry.steer.id)
|
||||
) {
|
||||
// A wake already landed it — or this turn's own request already
|
||||
// carries it (the pump path): the delivery is done either way,
|
||||
// just finish the bookkeeping. NEVER append twice.
|
||||
finalizeSteer(entry.steer);
|
||||
} else {
|
||||
claimed.push(entry.steer);
|
||||
|
||||
Reference in New Issue
Block a user