Repository navigation
fix(desktop): role-aware tail-first stamping — stop stamping the turn's ids onto stale zombies #364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -357,28 +357,72 @@ export function stampOptimisticTranscriptRows(messages: readonly ChatMessage[], | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const stampedIds = new Set<string>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let nextIdIndex = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const next = messages.map(message => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Number.isInteger(Number(message.id)) || nextIdIndex >= committedIds.length) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Stampable = any non-committed-id row: optimistic id (`user-`/`assistant-` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // prefix) OR still-pending. The streamed assistant row is finalized by | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // completeAssistantMessage() (pending:false, id `assistant-<ts>`) BEFORE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // markTurnComplete() runs this stamp — so a `pending`-only predicate would | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // skip it (#352). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const stampable: number[] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| messages.forEach((message, index) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Number.isInteger(Number(message.id))) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isOptimisticId = message.id.startsWith('user-') || message.id.startsWith('assistant-') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isOptimisticId || message.pending) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stampable.push(index) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ROLE-AWARE, TAIL-FIRST assignment. The frame's contract is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // [user_id, assistant_id] for the turn that JUST finished — but the list can | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // also hold STALE optimistic rows from an earlier turn whose completion | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // frame was severed by a backend restart (this session's live incident, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2026-07-15). The old top-down, role-blind walk stamped the USER id onto | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // that stale ASSISTANT zombie and the ASSISTANT id onto the real user row — | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the zombie then wore a committed id (invisible to the #361 sweep) and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // painted its stale text as a permanent duplicate, while the current turn's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // rows stayed un-stamped. Assign each id to the LAST unclaimed stampable row | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // of the matching role instead (a lone id keeps last-row-any-role), so stale | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // zombies stay optimistic — and therefore sweepable. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const assignment = new Map<number, string>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const claimed = new Set<number>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const claim = (id: string, wantRole: 'user' | 'assistant' | null): boolean => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let j = stampable.length - 1; j >= 0; j--) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const index = stampable[j] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (claimed.has(index)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (wantRole && messages[index].role !== wantRole) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assignment.set(index, id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claimed.add(index) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (committedIds.length === 2) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claim(committedIds[0], 'user') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claim(committedIds[1], 'assistant') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Lone id: the [user, assistant] positional contract can't disambiguate a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // single survivor. Prefer the streamed assistant row (the dup-prone one — | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the poll always re-fetches the final text bubble), fall back to any. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const id of committedIds) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!claim(id, 'assistant')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| claim(id, null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+404
to
+416
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const stampedIds = new Set<string>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const next = messages.map((message, index) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const id = assignment.get(index) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // An optimistic row still needs stamping whether or not it is still | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `pending`. The streamed assistant row is finalized by | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // completeAssistantMessage() (pending:false, id `assistant-<ts>`) BEFORE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // markTurnComplete() runs this stamp — so a `pending`-only predicate skips | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // it, it keeps its optimistic id, and the post-completion poll re-appends | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the committed integer row as a DUPLICATE. Recognize any non-committed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // optimistic id (`user-`/`assistant-` prefix) OR a still-pending row. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isOptimisticId = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message.id.startsWith('user-') || message.id.startsWith('assistant-') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isOptimisticId && !message.pending) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (id === undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const id = committedIds[nextIdIndex] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nextIdIndex += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stampedIds.add(id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { ...message, id, pending: false } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the two-id branch,
claim(committedIds[0], 'user')returnsfalsewhen there are no unclaimed user-role rows instampable— and the return value is silently discarded. Unlike the lone-id path (which falls back toclaim(id, null)), the user ID is simply lost. This is fine if the user row was already committed before this stamp fires, but it could silently drop a valid user ID in any edge case where the optimistic user row was cleared without getting a committed integer ID. Adding a fallbackclaim(committedIds[0], null)when the role-specific claim fails would mirror the lone-id path's defensive behaviour.