Skip to content

fix(desktop): role-aware tail-first stamping — stop stamping the turn's ids onto stale zombies - #364

Closed
Kyzcreig wants to merge 1 commit into
mainfrom
fix/role-aware-stamp
Closed

Kyzcreig wants to merge 1 commit into
mainfrom
fix/role-aware-stamp

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Symptom (last unexplained double-paint mechanism)

In ONE long, restart-scarred session: the newest assistant reply painted twice, DB and render cache both verifiably clean, fresh sessions unaffected. Survived #352/#357/#361/#362.

Root cause — caught live via CDP-attach to the running app

The session held a stale optimistic assistant row (completion frame severed by an earlier backend restart). On the next turn's message.complete, the stamp walked non-committed rows top-down and role-blind: the frame's user_id landed on the stale ASSISTANT zombie, assistant_id on the fresh USER row. The zombie now wore a committed id — invisible to the #361 sweep, which only targets optimistic ids — so its stale text painted as a permanent duplicate and the fresh rows stayed un-stamped. Deterministically 'the last message'.

Fix

Role-aware, tail-first assignment in stampOptimisticTranscriptRows: with the [user_id, assistant_id] contract each id claims the last unclaimed stampable row of its role; a lone id prefers the assistant streamed row. Stale zombies keep optimistic ids → remain sweepable by #361 when their committed twins arrive via the poll (covered end-to-end in the new tests).

Tests

4 new cases; RED-proof: restoring the old top-down walk fails 3 of them. Full desktop suite 1334/1334, tsc clean.

…'s ids onto stale zombies

CDP-attach to the live app (2026-07-15) caught the last unexplained double-
paint mechanism in the act. A long session held a STALE optimistic assistant
row (its completion frame severed by an earlier backend restart). When the
NEXT turn completed, markTurnComplete stamped the frame's [user_id,
assistant_id] with a top-down, role-blind walk over non-committed rows:
user_id landed on the stale ASSISTANT zombie and assistant_id on the fresh
USER row. The zombie now wore a committed id — invisible to the #361 sweep
(which only targets optimistic ids) — so its stale text painted as a
permanent duplicate, and the actually-fresh rows stayed un-stamped. Always
'the last message', only in restart-scarred sessions: exactly the reported
symptom.

stampOptimisticTranscriptRows now assigns role-aware and tail-first: with the
[user, assistant] two-id contract, each id claims the LAST unclaimed
stampable row of its role; a lone id prefers the assistant streamed row.
Stale zombies stay optimistic — and therefore remain sweepable by #361 when
their committed twins arrive via the poll.

Tests: 4 new (mis-stamp guard, end-to-end zombie sweep after correct stamp,
normal-path unchanged, lone-id preference). RED-proven: restoring the old
top-down walk fails 3. Suite 1334/1334, tsc clean.
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the old top-down, role-blind stamping walk in stampOptimisticTranscriptRows with a role-aware, tail-first assignment strategy to prevent stale optimistic assistant rows (zombies from severed backend-restart sessions) from being stamped with the wrong committed IDs and becoming unsweepable duplicates.

  • Core fix: The new claim() helper scans stampable[] from tail to head and filters by role, so [user_id, assistant_id] each go to the last unclaimed row of their respective role; zombies that don't match keep their optimistic IDs and remain sweepable by the #361 poll.
  • Lone-id path: When exactly one committed ID arrives, the code prefers the last assistant-role row (the dup-prone streamed bubble) and falls back to any row — mirroring the historical intent.
  • Tests: Four new RED-proof cases cover the mis-stamp scenario, the end-to-end sweep after stamping, the normal send path, and lone-id preference.

Confidence Score: 4/5

Safe to merge for the targeted bug; the two edge cases flagged are unlikely in normal operation and do not affect the happy path or the documented zombie scenario.

The core role-aware stamping logic is correct and well-tested against the specific live regression. Two edge cases in the else branch deserve attention: when extractCommittedMessageIds produces more than two IDs (e.g., both an array field and a scalar field are non-empty in the same payload), the loop applies assistant-first preference to every ID including the positional user ID, which can reproduce the very cross-role mis-stamp this PR fixes. The two-id path also silently drops the user ID rather than falling back if no user-role stampable row is found.

The else branch in stampOptimisticTranscriptRows in use-session-changes.ts handles the length-not-2 case in a way that conflates lone-ID and 3+-ID payloads — worth a second look before extending the backend contract.

Important Files Changed

Filename Overview
apps/desktop/src/app/chat/hooks/use-session-changes.ts Replaces the top-down role-blind stamp walk with a role-aware tail-first assignment strategy; the two-id path has no fallback when a role has no matching stampable row, and the else branch silently handles length > 2 with assistant-first logic that may mis-assign positional user IDs.
apps/desktop/src/app/chat/hooks/use-session-changes.test.ts Adds 4 targeted new test cases covering the stale-zombie mis-stamp scenario, end-to-end sweep, normal send path, and lone-id preference; all cases are well-structured and RED-proof per the PR description.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["message.complete fires\n[user_id, assistant_id]"] --> B["stampOptimisticTranscriptRows"]
    B --> C{"committedIds.length?"}
    C -- "0" --> D["return unchanged (early exit)"]
    C -- "2" --> E["Build stampable[] from optimistic/pending rows"]
    C -- "1 or >2" --> F["else branch\n(lone-id loop)"]
    E --> G["claim(user_id, role='user')\ntail-first scan"]
    E --> H["claim(assistant_id, role='assistant')\ntail-first scan"]
    G -- "found" --> I["assign user_id to last user-role row"]
    G -- "not found" --> J["user_id dropped\n(no fallback)"]
    H -- "found" --> K["assign assistant_id to last assistant-role row"]
    H -- "not found" --> L["assistant_id dropped\n(no fallback)"]
    F --> M["for each id:\ntry claim(id, 'assistant')"]
    M -- "success" --> N["assigned to last assistant row"]
    M -- "failure" --> O["fallback: claim(id, null)\nassign to last any-role row"]
    I & K --> P["Stale zombies keep\noptimistic ids → sweepable"]
    P --> Q["Next poll: dropZombieOptimisticRows\nreplaces zombie with committed twin"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["message.complete fires\n[user_id, assistant_id]"] --> B["stampOptimisticTranscriptRows"]
    B --> C{"committedIds.length?"}
    C -- "0" --> D["return unchanged (early exit)"]
    C -- "2" --> E["Build stampable[] from optimistic/pending rows"]
    C -- "1 or >2" --> F["else branch\n(lone-id loop)"]
    E --> G["claim(user_id, role='user')\ntail-first scan"]
    E --> H["claim(assistant_id, role='assistant')\ntail-first scan"]
    G -- "found" --> I["assign user_id to last user-role row"]
    G -- "not found" --> J["user_id dropped\n(no fallback)"]
    H -- "found" --> K["assign assistant_id to last assistant-role row"]
    H -- "not found" --> L["assistant_id dropped\n(no fallback)"]
    F --> M["for each id:\ntry claim(id, 'assistant')"]
    M -- "success" --> N["assigned to last assistant row"]
    M -- "failure" --> O["fallback: claim(id, null)\nassign to last any-role row"]
    I & K --> P["Stale zombies keep\noptimistic ids → sweepable"]
    P --> Q["Next poll: dropZombieOptimisticRows\nreplaces zombie with committed twin"]
Loading

Reviews (1): Last reviewed commit: "fix(desktop): role-aware tail-first stam..." | Re-trigger Greptile

Comment on lines +404 to +416
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)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The else branch handles every committedIds.length !== 2 case — including lengths of 3 or more — but its logic and comment only make sense for exactly one ID. extractCommittedMessageIds can return 3+ IDs when both an array field (message_ids) and a scalar field (message_id) are non-empty in the same payload. In that situation the for loop calls claim(id, 'assistant') for every ID, including committedIds[0] which is the positional user ID — repeating the exact cross-role mis-stamp this PR fixes, just for the >2 case. Tightening the condition to === 1 keeps the assistant-preference logic scoped to its documented intent.

Suggested change
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)
}
}
}
if (committedIds.length === 2) {
claim(committedIds[0], 'user')
claim(committedIds[1], 'assistant')
} else if (committedIds.length === 1) {
// 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.
if (!claim(committedIds[0], 'assistant')) {
claim(committedIds[0], null)
}
} else {
// Unexpected count (>2 or 0 after the early-exit guard): fall back to
// role-blind tail-first assignment so at least the tail rows get stamped.
for (const id of committedIds) {
claim(id, null)
}
}

Comment on lines +404 to +406
if (committedIds.length === 2) {
claim(committedIds[0], 'user')
claim(committedIds[1], 'assistant')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Silent drop when no user-role stampable row exists

In the two-id branch, claim(committedIds[0], 'user') returns false when there are no unclaimed user-role rows in stampable — and the return value is silently discarded. Unlike the lone-id path (which falls back to claim(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 fallback claim(committedIds[0], null) when the role-specific claim fails would mirror the lone-id path's defensive behaviour.

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

Superseded by #397 — a re-land onto current main (this branch is 705 commits behind and conflicts with the livesync Phase-1 hook changes). Both LIVE Greptile findings are adjudicated there: the no-user-row silent drop is deliberate (documented + test-pinned — cross-role fallback would re-create the mis-stamp), and 3+-id frames are handled + test-pinned. RED-proven against the old impl. Authorship credited via Co-authored-by.

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

Correction: the re-land PR is #398 (not #397).

Kyzcreig added a commit that referenced this pull request Jul 18, 2026
… ids onto stale zombies (re-land of #364)

Re-lands #364 (author: Kyzcreig; branch was 705 commits behind) onto current main. CDP-attach caught the mechanism live 2026-07-15: a stale optimistic assistant row (completion frame severed by a backend restart) absorbed the fresh turn user_id via the old top-down role-blind walk — the zombie wore a committed id (invisible to the #361 sweep) and painted as a permanent duplicate. stampOptimisticTranscriptRows now assigns role-aware and tail-first.

Both LIVE Greptile #364 findings adjudicated: (1) silent user-id drop when no user-role stampable row exists is DELIBERATE and now documented + test-pinned — cross-role fallback would re-create the exact mis-stamp this fixes; the poll reconciles the committed row safely. (2) 3+-id frames (array + scalar fields both populated) now documented + test-pinned: assistant-preferring tail-first walk, extras left for the poll, no crash.

RED-proven: swapping back the fork/main top-down impl fails 5/36 (the 3 zombie vectors + both edge-frame tests). Hook suite 36 green; full desktop suite delta vs clean fork/main baseline = zero new failures (17 pre-existing reds on both, 1 local-env flake passes 3/3 in isolation); tsc errors identical to baseline (7, all pre-existing).

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Kyzcreig added a commit that referenced this pull request Jul 18, 2026
* fix(desktop): role-aware tail-first stamping — stop stamping the turn ids onto stale zombies (re-land of #364)

Re-lands #364 (author: Kyzcreig; branch was 705 commits behind) onto current main. CDP-attach caught the mechanism live 2026-07-15: a stale optimistic assistant row (completion frame severed by a backend restart) absorbed the fresh turn user_id via the old top-down role-blind walk — the zombie wore a committed id (invisible to the #361 sweep) and painted as a permanent duplicate. stampOptimisticTranscriptRows now assigns role-aware and tail-first.

Both LIVE Greptile #364 findings adjudicated: (1) silent user-id drop when no user-role stampable row exists is DELIBERATE and now documented + test-pinned — cross-role fallback would re-create the exact mis-stamp this fixes; the poll reconciles the committed row safely. (2) 3+-id frames (array + scalar fields both populated) now documented + test-pinned: assistant-preferring tail-first walk, extras left for the poll, no crash.

RED-proven: swapping back the fork/main top-down impl fails 5/36 (the 3 zombie vectors + both edge-frame tests). Hook suite 36 green; full desktop suite delta vs clean fork/main baseline = zero new failures (17 pre-existing reds on both, 1 local-env flake passes 3/3 in isolation); tsc errors identical to baseline (7, all pre-existing).

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>

* fix(desktop): 3+-id frames keep the positional [user, assistant] pair role-aware (Greptile #398)

---------

Co-authored-by: Apollo <apollo@kyzcreig.local>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
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.

1 participant