Skip to content

fix(#6491): paginate synthesized foreign session loads - #6494

Open
rodboev wants to merge 40 commits into
nesquena:masterfrom
rodboev:pr/6491-foreign-session-paging
Open

rodboev wants to merge 40 commits into
nesquena:masterfrom
rodboev:pr/6491-foreign-session-paging

Conversation

@rodboev

@rodboev rodboev commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Foreign CLI, TUI, Desktop, and Claude Code sessions now use the native bounded message window. Full-history consumers still request complete transcripts when needed, and asynchronous sends, background results, /btw answers, Artifacts reads, and cancel cleanup remain tied to their initiating session.

The composer also keeps an accepted server draft authoritative when a failed send still holds the original browser File objects. A cleared or whitespace-only draft no longer regains stale text or attachments, while matching attachment metadata restores the original File objects and preserves newer accepted text.

What changed

  • Synthesized foreign sessions use the native msg_limit and msg_before response contract with full counts, offsets, truncation metadata, and metadata-only responses.
  • static/sessions.js reports whether composer hydration was accepted, including empty, whitespace-only, and file-only drafts.
  • static/messages.js compares accepted attachment descriptors with failed-send custody, including lastModified, restores exact original File objects only for matching descriptors, and retires stale custody for different or cleared drafts.
  • Existing session ownership repairs keep background results, /btw answers, Artifacts retries, terminal settlement, and cancel cleanup with their initiating sessions.

Why it matters

A session switch during a send preserves the draft for its owner. A newer accepted server draft cannot be overwritten by stale failed-send text or attachments. Long CLI sessions load bounded windows without changing the full-history paths that need complete transcripts.

Risks

Browser File objects remain page-local. A hard reload requires the user to select the attachments again. The server stores attachment metadata, while the page retains the original File objects for retry.

Screenshots

Before, empty owner composer

After, recovered text and attachment

Model Used

GPT-6 via Codex CLI.

Closes #6491

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds bounded message-window pagination for synthesized foreign sessions and updates full-history consumers to fetch complete transcripts when required. It also strengthens session ownership around asynchronous UI work and makes accepted server composer drafts authoritative over stale failed-send recovery.

  • Applies native message limits, offsets, and truncation metadata to synthesized foreign sessions.
  • Preserves full-history behavior for downloads, branching, compression, and artifact reads.
  • Restores original browser File objects only when accepted attachment metadata matches.
  • Prevents stale asynchronous results from mutating a different session's visible state.
  • Adds focused regression coverage for pagination, session switching, and draft recovery.

Confidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule violations were identified.

The synthesized-session pagination follows the native response contract, full-history consumers explicitly obtain complete snapshots, and the revised draft-recovery flow preserves accepted text while restoring files only for matching descriptors. Both previous review threads are resolved, with the whitespace-preservation issue covered by the current implementation and regression tests.

Important Files Changed

Filename Overview
api/routes.py Adds native bounded message-window behavior and pagination metadata to synthesized foreign-session responses.
static/messages.js Reconciles failed-send custody with accepted server drafts while preserving session ownership and exact browser File objects.
static/sessions.js Reports accepted draft hydration, including empty, whitespace-only, and file-only states, and includes last-modified metadata in file signatures.
static/commands.js Adds session and transcript-generation guards around asynchronous command results and transcript replacements.
static/boot.js Loads a guarded full-session snapshot before downloading a truncated transcript.
tests/test_issue5472_preserve_draft_on_failed_send.py Covers accepted draft authority, exact File restoration, whitespace preservation, metadata collisions, and late failure races.

Sequence Diagram

sequenceDiagram
  participant UI as Browser UI
  participant API as Session API
  participant Source as Foreign session source
  UI->>API: GET session with msg_limit/msg_before
  API->>Source: Synthesize complete session
  Source-->>API: Messages and metadata
  API->>API: Select bounded display window
  API-->>UI: Messages, offset, truncation metadata
  alt Full history is required
    UI->>API: Request unbounded snapshot
    API-->>UI: Complete transcript
  end
  UI->>UI: Hydrate accepted composer draft
  UI->>UI: Match attachment descriptors
  alt Descriptors match failed-send custody
    UI->>UI: Restore original File objects
  else Draft cleared or descriptors differ
    UI->>UI: Retire stale custody
  end
Loading

Reviews (18): Last reviewed commit: "fix(#6491): honor accepted drafts during..." | Re-trigger Greptile

Comment thread api/routes.py
@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from 54782c2 to ac271fc Compare July 24, 2026 23:46
@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jul 25, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — RED ⛔

Certified head: sha:ac271fc54984 (ac271fc549845108cd92494fffdf1c08faf221f9) · PR: #6494 · @rodboev, fix(#6491): paginate synthesized foreign session loads

Verdict: The backend paging implementation is correct in isolation, but it silently truncates two existing full-history consumers for long foreign sessions: Markdown download and the Artifacts inventory.

What I ran

Gate Result
Exact identity / threat / diff Head and warmed SHA matched; threat scan CLEAN; git diff --check clean
Codex xhigh (reproduce) SHIP ONLY WITH FIXES; found the two downstream full-history regressions below
Fable 5 xhigh APPROVE after tracing native/foreign paging and proving new tests red on master; it classified _msg_limit_max as optional, but missed the two downstream consumers
Focused pytest 105 passed across tail payload, message-window/tool handling, limit ceiling, CLI claim/import, issue-734, and foreign-session suites
Regression non-vacuity Frozen-base api/routes.py: all 4 new foreign-session tests failed; restored exact head: all 4 passed
Independent route probe PASS: 601-row clamp, gap-free backward pages, metadata-only, legacy unbounded load, limit parsing, structured tool clipping without source mutation
Consumer reproduction PASS_REPRODUCED_SILENT_LOSS under sandboxed Node using the real transcript() and collectSessionArtifacts() implementations
Candidate full suite Reached 13,782 passed, 83 skipped; 8 failures + 2 wheel-build errors were outside this PR footprint and are not used as PR findings. Base run was stopped after the decisive reproduced RED.

Findings

  • api/routes.py:13017-13078 correctly mirrors the native paging order: _message_window_for_display() followed by _messages_for_limited_payload(). Metadata-only loads return no transcript, unbounded callers retain the compatibility full-load path, offsets use raw full-history coordinates, matched trailing tool results remain renderable, and todo hydration still reads full history.

  • Markdown download silently omits older turns. static/boot.js:2051-2055 calls transcript() synchronously; static/messages.js:6791-6803 serializes only the current S.messages. On this head, a long foreign session now leaves only the bounded tail in S.messages. My direct reproduction showed the full transcript contained OLD_TRANSCRIPT_UNIQUE, while the downloaded tail did not.

    Required fix: before generating Markdown, capture the session ID, load the complete unbounded transcript (the existing _ensureAllMessagesLoaded() path is the natural primitive), then verify the active session still owns that ID before calling transcript(). Add a regression test with an older unique turn outside the initial window and assert it appears in the download.

  • Artifacts silently omit files from older turns. Switching tabs calls renderSessionArtifacts() synchronously at static/workspace.js:372-395; collectSessionArtifacts() at static/workspace.js:520-565 scans only current S.toolCalls and S.messages. Synthesized foreign responses expose tool_calls: [], so an artifact outside the tail has no other recovery path. My direct reproduction returned ["old/deep.txt", "new/live.txt"] from full history but only ["new/live.txt"] from the tail.

    Required fix: when the Artifacts tab activates with truncated history, load complete unbounded messages before collecting artifacts. Guard the async continuation by captured session ID and active tab so a session/tab switch cannot render stale results. Add a regression test with old/new write_file calls split across the initial paging boundary.

  • ⚠️ Non-blocking contract parity: the synthesized response omits _msg_limit_max, unlike the native branch. The frontend fallback is also 500 today, so this does not break current behavior, but adding sess["_msg_limit_max"] = _MAX_MSG_LIMIT would keep foreign sessions inside tech(contract): expose msg_limit ceiling in /api/session metadata so the frontend doesn't hand-mirror _MAX_MSG_LIMIT #6177's no-drift contract.

  • ⚠️ Out-of-scope follow-up: messages=0 now bounds wire/DOM cost, but _claim_or_synthesize_cli_session() still materializes full state.db history server-side. That is not a regression from this PR and does not block the issue's stated scope.

Recommendation to the next agent

RED — fix both full-history consumers, add boundary regressions, then let the new head settle, re-warm, and re-enter exact-head gate certification. Do not merge this head.


Gate-certifier layer (warm-up → gate → release). I do not merge, tag, deploy, or close. This certificate is valid only while the head remains ac271fc549845108cd92494fffdf1c08faf221f9.

@nesquena-hermes nesquena-hermes added gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Jul 25, 2026
@rodboev

rodboev commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I reproduced the two tail-only consumers you called out.

  1. The backend paging change itself stays intact. The remaining break is that Markdown export still serializes the current S.messages tail through transcript(), so an older foreign-session turn never gets loaded before the blob is built.

  2. The Artifacts tab has the same shape. It derives from current S.toolCalls and S.messages, so once the foreign session is tail-loaded it loses older write_file outputs even though the session history itself is still present server-side.

  3. I’m fixing both by routing those two consumers through the existing _ensureAllMessagesLoaded() path instead of adding a second paging mechanism. The download path will bail if the active session changes while it waits, and the Artifacts path will bail on either a session switch or a tab switch before rendering.

  4. I’m adding focused regressions for both cases: one with an older unique turn outside the initial foreign-session window for Markdown export, and one with old/new write_file calls split across the paging boundary for the Artifacts tab.

  5. I also added the synthesized _msg_limit_max field so the foreign payload exposes the same paging ceiling metadata as the native session path.

I’ll follow up on the PR with the updated head once those regressions are in and passing.

@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from 36bcff1 to f233798 Compare July 25, 2026 19:46
@nesquena-hermes nesquena-hermes added size:L Large PR (>10 files or >250 LOC) and removed changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address size:M Medium PR (≤10 files, ≤250 LOC) labels Jul 25, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate re-review — SHIP ONLY WITH FIXES ⛔

Gated head: f2337987bfc0 (rebased clean onto current master a7180a810, 3-way, 0 conflicts; the 3 static files are byte-identical to the PR head). Full single-process suite 13,751 passed / 0 failed, but two independent advisor legs each reproduced a real defect the green suite masks. The backend paging change itself (api/routes.py) is sound and correctly fixes #6491 — the blockers are in the frontend full-load consumers.

🔴 CORE — completed turn can vanish from the transcript (static/sessions.js:3830)

The new in-flight guard at sessions.js:3838-3840 only samples current S.busy || S.activeStreamId. If a turn starts and finishes entirely while the up-to-120s full-history fetch is pending ({timeoutMs:120000}, line 3831), both flags are idle again by the time the response returns, the guard passes, and line 3854 (S.messages = _msgsToAssign) overwrites the freshly-settled transcript with the stale pre-fetch snapshot + sets _messagesTruncated = false. The just-completed answer disappears from view until a reload.

Reproduced against the real extracted function: a ["NEW TURN","NEW ANSWER"] transcript was replaced by old history and _messagesTruncated flipped to false. The existing _bumpMessagesGeneration() mechanism protects the prefetch race (older-message pagination) but not this settle-during-fetch case.

Fix: capture a transcript/session revision (or monotonic ownership epoch) before the await, and reject the response if that identity changed even when S.busy/S.activeStreamId have returned to idle. Add a start-and-settle-during-fetch regression test (a turn that begins and completes while the full-history request is in flight must not be clobbered).

🟠 SILENT/VISIBLE — Artifacts tab stays blank after non-done terminal exits (static/workspace.js:626)

renderSessionArtifacts blanks the pane (root.innerHTML='', count '') while busy on a truncated session, but the non-done terminal paths in static/messages.js (:6285 apperror, :6517 cancel, :6744 disconnect) clear busy state without re-rendering artifacts. The pane then stays empty until an unrelated tab-switch or refresh. During a live streaming turn on a still-truncated session, the ~100ms-debounced re-renders keep re-blanking it, so the tab shows nothing for the whole turn — a visible regression vs master, which showed the partial list.

Fix: trigger a guarded renderSessionArtifacts() after every active-pane terminal transcript settles — including the async cancel-reconciliation, apperror, and disconnect paths — so a deliberately-blanked truncated pane can't remain empty.

UX polish (fold in with the above; all use existing idioms)

  1. Loading placeholder, not blank (workspace.js:626-629): render a workspace-artifact-empty "Loading full history…" placeholder (new data-i18n key) instead of innerHTML='', and keep the prior count rather than clearing it — self-explains the blank-during-stream window.
  2. Failure fallback (workspace.js:640): on .catch, fall back to _renderNow() (partial list) or an error empty-state rather than leaving the pane blank.
  3. Gate the full-load on real panel visibility (workspace.js:618-624): reuse the _workspaceTodosTabIsActive() pattern (rightpanel dataset.activeTab + !hidden on tab & panel) so a collapsed/off-canvas workspace panel doesn't fire the multi-MB history fetch this PR exists to avoid.
  4. Download feedback (boot.js:2052-2061): showToast('Preparing full transcript…') before the await, toast on failure and on the busy/streaming bail — or disable btnDownload while S.busy on truncated sessions. A visible, enabled button that silently no-ops (or blocks up to 120s with no spinner) shouldn't ship.

The backend contract, attach_todo_state on full msgs, _messages_offset/_messages_truncated/_msg_limit_max stamping, and the msg_before-reaches-offset-0 path all check out. Fix items 1 & 2 are the ship blockers; 3–6 are small consistency fixes best folded into the same round. Once the CORE race is closed and the terminal-exit re-render is wired, this is a strong ship (it touches the workspace pane, so it'll want a maintainer screenshot pass on the Artifacts loading/loaded/streaming states + mobile before merge).

Thanks @rodboev — the paging core is right; it's the full-load consumers' lifecycle coverage that needs the extra round.

@nesquena-hermes nesquena-hermes added the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Jul 26, 2026
@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from f233798 to 65b1e99 Compare July 26, 2026 20:15
@rodboev

rodboev commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two blockers from #6494 (comment) on the current head 3a8b8389.

The core _ensureAllMessagesLoaded() race now snapshots _messagesGeneration before the await and rejects the response if a same-session live turn claimed ownership while the full-history fetch was in flight. Every live-turn claim site now bumps that generation before touching S.activeStreamId or replacing S.messages, so a settle-during-fetch transcript can’t clobber the newer state.

The Artifacts path now covers the non-done terminal exits the review called out. The pane shows a loading placeholder instead of blanking, only triggers the full-history load when the Artifacts tab is actually visible, falls back to the partial list on failure, and re-renders after active-pane done, apperror, cancel, and disconnect terminal settles.

I also folded in the download feedback asks from the same review round: preparing, busy, failed, and session-changed toasts now cover the full-history export path.

Regression coverage is in with the real extracted sources behind the harnesses. CI then exposed two older extracted-node fixtures that also needed the new generation helper/state wired into their fake module scope, so I followed up with two test-only commits for those harnesses. Current head is green on GitHub Actions and Greptile.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-gate at 3a8b838926e5: the generation fence still misses production writers

The current re-push closes the ordinary send() path, but it does not close the writer graph required by the prior settle-during-fetch blocker.

Blocker: /goal can still be overwritten by a stale full-history response

static/sessions.js::_ensureAllMessagesLoaded() snapshots _messagesGeneration before its awaited full-history request, then accepts the response whenever the generation is unchanged and S.busy / S.activeStreamId are idle again.

static/commands.js::cmdGoal() is a same-session live-turn claim that never bumps that generation:

  • it appends the goal status to S.messages at commands.js:1247-1250;
  • it sets busy state and S.activeStreamId at commands.js:1253-1257;
  • it attaches the live stream at commands.js:1270;
  • neither cmdGoal(), setBusy(), nor that claim path calls _bumpMessagesGeneration().

That leaves the exact race from the prior gate open:

  1. A truncated-session full-history load starts and captures generation N.
  2. /goal starts and completes while that request is pending.
  3. The pane is idle again, but the generation is still N.
  4. The old response passes both guards and replaces S.messages, hiding the just-completed goal turn.

The same missing-writer class exists in static/commands.js::_applyManualCompressionResult() (wholesale S.messages replacement) and static/ui.js::refreshSession(). The new behavior test manually invokes _bumpMessagesGeneration() inside its fake transition, while its wiring assertion checks only sessions.js and messages.js, so it cannot catch these production omissions.

Required fix

Close the ownership graph centrally, or bump before every same-session live claim and wholesale transcript replacement. At minimum cover /goal, manual-compression result application, and refresh. Add a production-composed start-and-settle-during-await regression for /goal (plus a stable-generation owner control); the transition under test must not manually supply the bump it is supposed to verify.

The Artifacts fallback and feedback changes match the latest maintainer fix guidance and are not blockers in this review.

This was an exact-head static re-gate. The threat scanner still reports SUSPICIOUS on the existing eval(downloadAssign) harness, so policy prohibited local execution; no test pass is claimed. The blocker is code-level and independent of that scan verdict.

@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from 3a8b838 to 97b29b5 Compare July 26, 2026 21:58
@rodboev

rodboev commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I missed that _ensureAllMessagesLoaded() still had command-side writers outside the generation invalidation path.

  1. The follow-up keeps the current full-history loader and closes the remaining same-session writer graph instead of adding another history path. The missing invalidations were in static/commands.js, plus the same-session refresh and truncate rewrites in static/ui.js, and the active clear path in static/panels.js.

  2. /goal was the concrete missing live-claim case in this review. The branch now invalidates pending full-history loads before the goal status row and before the goal stream claim, so a late full-history response can’t hide a completed goal turn after the pane returns to idle.

  3. The same invalidation now fronts the other same-session command-side transcript writes and replacements that shared the class: command output rows, manual compression apply, retry, undo, clear, refresh, and the truncate-based edit/regenerate rewrites.

  4. I replaced the synthetic mid-fetch mutation check with a real extracted cmdGoal() race in tests/test_foreign_session_full_history_consumers.py, and kept the stable-generation control beside it. That focused regression file and runtime ESLint are green on the new head.

@nesquena-hermes nesquena-hermes removed the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Jul 26, 2026
@nesquena-hermes
nesquena-hermes force-pushed the pr/6491-foreign-session-paging branch from 97b29b5 to 146d5b8 Compare July 29, 2026 23:50
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-gated the current head (rebased clean onto master — I did the rebase, your commits + authorship preserved). The core fix is solid and the full suite is green (13,924 / 0), but the Codex adversarial gate reproduced two SILENT-severity defects the tests don't cover, so this needs one more pass before it can ship:

1. static/messages.js:1435 — echoed slash-command can vanish under a pending full-history load.
An asynchronous /goal (or other local slash command) echo can disappear when a pending full-history response overwrites S.messages before the command handler bumps _messagesGeneration. Reproduced: the goal result survived but its user command row vanished. Fix: claim _messagesGeneration ownership before every local/echoed slash-command transcript write, including the sibling branches through ~line 1554, and add the delayed-command-response ordering to the regression test.

2. static/workspace.js:646 — Artifacts pane can stick on "Loading full history…" forever.
When _ensureAllMessagesLoaded() resolves but its generation guard rejects stale data (so _messagesTruncated stays set), the Artifacts pane never leaves the loading placeholder. Fix: for the same active session + visible tab, restore _renderNow() when the resolved load is still truncated; add the successful-no-op case to the test.

Verified intact (no change needed): native-session backend windowing is unchanged; foreign sessions return the newest bounded tail, page backward correctly, preserve full-history todo derivation + the no-limit escape hatch, and keep profile isolation.

Once those two are wired (both are small, localized to the generation-ownership edges), re-request and I'll re-gate at the exact head.

Heads-up on scope: this overlaps #6578 (also Closes #6491) — the maintainer will pick one, and this fuller-lifecycle version (real "Load earlier" affordance + full-history-consumer coverage) is the stronger candidate once these two edges are closed.

@rodboev

rodboev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I found the remaining gap in the generation fence. The reader was checking the right revision, but several production writers were not claiming it.

  1. I’ll claim the shared transcript generation before /goal, manual compression replacement, refresh, and every local or echoed slash-command write.
  2. The full-history loader will keep rejecting a response when that revision changes, even if the live turn has already returned to idle.
  3. I’ll replace the harness-only bump with production-composed coverage for /goal settling during the request and for a delayed slash-command echo.
  4. The awaited /goal and compression paths now stop when the user has changed sessions, so an old result cannot write into the visible session.

The backend paging contract and the existing Markdown and Artifacts snapshot design stay unchanged.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-gate at a551091a9ac4: transcript ownership is still incomplete

The two new commits fix the narrow schedule where an older full-history response erases a slash-command row appended after that request began, and a551091a9ac4 adds useful cross-session guards around compression and /goal. The broader ownership contract still has deterministic stale-writer paths, and the previously reported Artifacts placeholder bug is unchanged.

Blocker 1: same-session replacements can reclaim ownership after a newer write

refreshSession() claims before its session GET, but after the await it calls _claimTranscriptWrite() again and replaces S.session / S.messages (static/ui.js:9566-9574). If a command echo or output lands while that GET is pending, the older refresh continuation bumps the generation again and erases the newer row. Checking only that the session ID is unchanged does not order two writers for the same session.

_runManualCompression() has the same same-session shape (static/commands.js:961-975). The latest commit correctly rejects a session switch after the preflight GET, but an older preflight for the same session still claims again and replaces after the await without comparing its original generation. A newer same-session command row can still be lost.

Blocker 2: async command results are not bound to their originating session

The pet, agent-command, and plugin-command paths append a user row, await work, then append their assistant result through global S.messages without checking the originating session (static/messages.js:1452-1468, 1493-1507, 1510-1524). Switching to session B while the await is pending lets session A's result land in B. The latest commit does not change these paths.

Blocker 3: the Artifacts loading placeholder can still remain forever

renderSessionArtifacts() installs “Loading full history…”, awaits _ensureAllMessagesLoaded(), then returns without repainting whenever _messagesTruncated remains true (static/workspace.js:636-648). A generation-guard abort is a fulfilled early return, not an exception, so the loading placeholder remains installed. Neither new commit changes this file or adds coverage for that fulfilled-abort outcome.

Required fix

  1. Make replacement ownership compare-and-commit. Capture {sessionId, generation} before an await; after it, commit only if both the session and captured generation are still current. Do not let an older continuation acquire a fresh generation immediately before replacing the transcript.
  2. Apply that rule to refreshSession(), manual-compression preflight/completion, and every other awaited wholesale S.messages = ... writer.
  3. Bind async pet, agent-command, plugin-command, and equivalent completions to the session that started them. Re-check ownership after every await before mutating transcript, tool, busy, stream, or inflight state.
  4. On a fulfilled guarded-abort from _ensureAllMessagesLoaded(), retire the Artifacts loading placeholder and render the current partial state when the same Artifacts pane is still active.
  5. Add behavioral interleaving tests for same-session refresh/compression versus a newer command row, session switch/null during async command completion, and the real Artifacts fulfilled-abort path. Source-string claim-placement assertions do not prove these schedules.

The mandatory exact-head threat scan classified the test harness as SUSPICIOUS because of five eval(...) production-source extraction calls, so I did not execute PR code or tests. That NO-RUN decision is execution policy, not a code-quality finding. The blockers above are verified from the exact-head production control flow.

@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from a551091 to 087aa36 Compare July 31, 2026 22:09
@rodboev

rodboev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I found the remaining ownership gaps and rebuilt the frontend side around one captured session and generation ticket.

  • Full-history Markdown and Artifacts reads now consume explicit snapshots. They no longer take ownership of the active transcript.
  • Refresh, compression, retry, undo, edit, regenerate, outline hydration, and clear use compare-and-commit checks before replacing messages.
  • Pet, Agent, plugin, MoA, bundle, background-task, and compression completions recheck their originating pane before mutating state.
  • Artifacts now repaints the current partial or full state after success, failure, and a fulfilled request rejected by a newer generation.
  • I kept the existing zero-argument transcript and artifact collector callers compatible and added regressions for the session-switch and same-session interleavings.

The branch is rebased onto current master. Focused and adjacent validation is green, and the before/after Artifacts screenshots are included in the updated body.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-gate at 3349561a6f1a: two transcript writers still bypass generation ownership

The current head fixes the four previously named direct paths. Refresh and manual compression now use compare-and-commit, async slash-command results are bound to their originating pane, slash rows claim generation before writes, and the Artifacts fulfilled-abort path repaints the partial list.

Two production writers remain outside the same ownership protocol, so an older full-history continuation can still erase newer same-session rows.

1. Gateway/CLI refresh replaces S.messages without claiming generation

In static/sessions.js:6285-6302, the gateway SSE path awaits /api/session/import_cli, validates the active session and prefix, then assigns:

S.messages = _nextToAssign;

It neither advances _messagesGeneration nor commits through the shared replacement-ticket primitive. The deterministic ordering is:

  1. _ensureAllMessagesLoaded() starts at generation G.
  2. A same-session gateway refresh installs newer imported rows, still at G.
  3. The older full-history request returns, sees its ticket as current, and replaces the refresh.

_carryForwardEphemeralTurnFields() does not prevent this. It copies named fields on matching rows, not newly appended rows.

2. Accepted background completion appends without claiming generation

static/messages.js:8914-8940 correctly checks the originating pane and captured generation. After those checks pass, line 8937 appends the completed background result directly:

S.messages.push(msg);

Because that accepted write does not advance _messagesGeneration, a full-history request that started earlier at the same generation can settle afterward and erase the background result.

Required fix

  1. Route the accepted gateway/CLI replacement through the shared transcript replacement commit primitive, or claim generation immediately before the final assignment with no await between the final ownership checks and the write.
  2. In startBackgroundPolling(), advance _messagesGeneration after the owner/generation guard and immediately before appending the accepted result.
  3. Add composed behavioral regressions for both schedules: pending _ensureAllMessagesLoaded() followed by the newer writer, then settlement of the old full-history request. Assert the imported/background row survives and the stale load cannot commit.
  4. Expand the writer-graph coverage beyond the currently selected source-string checks so every independent active-transcript append/replacement must claim generation or prove it runs under an existing claim.

Verification

The mandatory threat scan was CLEAN. Sandboxed targeted tests passed: test_foreign_session_full_history_consumers.py was 20 passed, and the focused cross-session slice was 3 passed, 35 deselected. git diff --check origin/master...HEAD also passed. Those tests do not compose either omitted writer with a pending full-history replacement, which is why the lost-row paths remain green.

Please re-request review after these two writer edges and their interleaving tests are in.

@rodboev

rodboev commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I found both remaining generation-fence gaps and pushed f779d1e. Gateway/CLI imports now commit through the captured session-and-generation replacement ticket, and accepted background results advance the generation immediately before their append. I added production-composed regressions where each writer settles before a pending full-history load, plus writer-graph coverage for both authorities. The existing foreign-session paging, Markdown, Artifacts, and session-ownership behavior remains unchanged.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — RED ⛔

Certified head: f779d1e05881d79a70af046cd69aee05aa814daf · tested base: 320789ae596a3963d726d90f6c7f3bc86f7f2d6d · exact-head integration (the contributor head already contains current master)

The transcript-ownership fixes for gateway imports and accepted background completions are real, but the full-history consumer UI still has reproducible regressions on long/truncated sessions. This head is not gate-clean.

Gate evidence

Gate Result
Identity / threat Exact live head; CLEAN threat scan, score 0; git diff --check clean
Focused regression set 86 passed across all six changed regression files
Mutation sensitivity Removing the accepted-background generation bump turned its interleaving test RED; bypassing the gateway replacement ticket turned its interleaving test RED
Codex xhigh SHIP ONLY WITH FIXES; production-extracted probes found additional transcript/reconciliation interleavings
Opus xhigh SHIP; real child argv verified claude --model claude-opus-4-8
Fable xhigh SHIP-WITH-UX-FIXES
Real browser Exact-head isolated server with 12 seeded sessions; rich four-message Markdown/MathML transcript settled on 1440×900 and 390×844 CSS viewports; zero organic console errors. The three behavior probes below reproduced deterministically.
Full sandboxed suite Raw result: 13,843 passed, 87 skipped, 1 xfailed, 2 xpassed; 4 failed + 2 errors. A matched-master slice was attempted, but the mandatory threat re-scan was blocked by the REST pulls endpoint's quota response before either control executed, so no baseline attribution is claimed. The product findings below are independently decisive.

Reproduced blockers

  1. Artifacts blanks live content during a stream (static/workspace.js:641-644). In a truncated session with one current live artifact, the production renderer produced expectedLiveItems=1, renderedItems=0, stale count 0, and replaced the pane with “Loading full history…” even though the busy/active-stream branch immediately returns without loading anything. Long conversations lose the live Artifacts view for the whole response.

  2. Artifacts refetches full history on every render (static/workspace.js:633-657). Two settled calls to the production renderSessionArtifacts() for the same session and generation issued two _readFullSessionSnapshot() requests. There is no in-flight/per-generation cache, so ordinary workspace refreshes can flash the placeholder and redownload the full transcript repeatedly.

  3. Markdown download has no re-entry guard (static/boot.js:2054-2117). Double-invoking the real handler while the first full-history request was pending produced calls=2, disabled=false, and no aria-busy. On a slow mobile/network path this creates parallel 120-second fetches and can generate duplicate downloads after the two-second toast disappears.

  4. Locale additions mask translation fallback (static/i18n.js). The five new English strings were copied into non-English locale maps rather than added only to en for the existing fallback. This hides untranslated-key coverage from localization tooling.

Required fix

  • While a truncated session is streaming, keep rendering the current Artifacts list and count; do not show a false loading state. If full history is deferred, say so without blanking live content.
  • Deduplicate/cache the Artifacts full-history snapshot by session plus transcript generation (or keep stale content visible while one refresh is in flight).
  • Add an operation-owned pending/re-entry guard to Markdown download, with a durable busy state until the fetch settles.
  • Remove the non-English English copies and rely on the existing en fallback, or provide real translations.
  • Add production-composed tests for the three browser schedules above, including repeated render and double-click behavior.

No merge, close, branch mutation, or release action was taken.

@rodboev
rodboev force-pushed the pr/6491-foreign-session-paging branch from 2593031 to a387a60 Compare September 8, 2026 02:10
@nesquena-hermes nesquena-hermes removed the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Sep 8, 2026

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-gate of a387a60f4827: hydrated attachment authority is still incomplete

Thanks for the follow-up. The prior blocker is closed: matching hydrated text no longer prevents the original attachment objects from being re-staged, and the new whitespace-preservation path keeps the visible draft unchanged.

One objective wrong-file case remains. projectSubmittedPayloadForOwner() decides whether a newer attachment draft conflicts by inspecting only S.pendingFiles (static/messages.js). But production _restoreComposerDraft() hydrates composer_draft.text and deliberately does not hydrate composer_draft.files into S.pendingFiles (static/sessions.js). After a newer cross-tab/server draft such as {text: "hello", files: [B metadata]} loads, the live tray is therefore empty. Projection treats that as the old matching draft and re-stages stale custody file A over newer file B.

The submitted file-conflict test does not cover production behavior because it manually sets S.pendingFiles = [fileB] before its abridged loadSession() runs. The real restore function cannot make that assignment. The focused sandboxed module is green (24 passed), but this fixture makes the affected branch false-green.

Required fix

  1. Carry the accepted hydrated draft payload or its canonical file signature into owner projection. Use the existing _composerDraftFilesForPersist() / _composerDraftPayloadSignature() machinery rather than relying only on live browser File references.
  2. Re-stage retained original File objects only when the hydrated descriptors represent the same submitted attachments. If a newer accepted draft has different non-empty descriptors, retire stale custody without staging the old files.
  3. Replace the masked conflict fixture with the production shape: S.session.composer_draft.files contains B metadata while S.pendingFiles is empty, then assert A is not resurrected. Keep coverage for matching descriptors restoring the original A object references, conflicting text, file-only drafts, whitespace preservation, and active/off-pane ownership.
  4. Define and test the precedence for an accepted intentionally empty or whitespace-only newer draft so a stale send snapshot cannot silently refill a draft the user cleared.

No merge/release action was taken. This is an exact-head review of a387a60f48276302f535e8ce85ed55ea717568ca.

@rodboev
rodboev marked this pull request as draft September 8, 2026 06:27
@rodboev
rodboev marked this pull request as ready for review September 8, 2026 06:35

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-gate of 64b0e81336b7: unreleased attachment custody can still be projected

Thanks for the follow-up. The prior stale-A-over-newer-accepted-B blocker is fixed: session hydration now passes the accepted draft into projectSubmittedPayloadForOwner(), and the production-shaped B fixture no longer manually stages B in S.pendingFiles.

One objective failed-send lifecycle blocker remains. send() stores _submittedPayloadCustody with released:false before /api/chat/start settles. During a switch away and back, projectSubmittedPayloadForOwner() lets any accepted-draft object bypass that unreleased guard (static/messages.js:1291-1295). It can then delete that custody for an accepted empty draft (1310-1312) or re-stage matching attachment A and delete custody (1323-1328).

That mutates ownership before the send outcome is known:

  • If the start later fails, _releaseSubmittedPayload() finds that its custody object is no longer in the map and cannot restore the original text/file identities.
  • If the start later succeeds, the premature projection can leave already-submitted A in the tray and persisted draft, allowing a duplicate submission.

The exact-head contributor module passes 36 tests in the no-network/no-secrets sandbox. A reviewer-owned discriminator against the real projector/recovery helpers reproduces both schedules as 2 failures; strict-xfail confirmation reports 2 xfailed. The submitted late_empty case currently asserts the loss (savedDrafts == []) rather than protecting failed-send recovery.

Required fix

  1. In static/messages.js::projectSubmittedPayloadForOwner(), never project, retire, or delete an unreleased custody record. Record any accepted-draft observation on that exact custody object without mutating the composer or tray.
  2. Adjudicate that observation only when _releaseSubmittedPayload() receives the actual success/failure outcome. A successful send must retire custody without leaving send-owned projection; a failed send must restore the exact original text and File identities unless a provably newer draft revision owns the composer.
  3. Distinguish the send's own clear/echo from a genuinely newer accepted empty draft, preferably with a server draft revision/write token rather than payload shape alone. _clearComposerDraft() should also clear file descriptors deliberately instead of POSTing only text:''.
  4. Replace the late_empty false oracle and add production-composed switch-away/switch-back coverage for both outcomes: accepted start leaves no A in tray/draft; rejected start restores A unless a newer revision owns the draft.

No full suite was run in this review-only warm-up pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI/foreign sessions bypass msg_limit: full transcript on every load, no Load earlier button

2 participants