feat(desktop)!: give the Renderer the transcript window - #5170
Conversation
Main owned the presentation window: a resident range with reading anchors, navigation versions, overlay settlement across pages and eviction bookkeeping, while the Renderer kept its own scroll state on top. Two owners of one window meant every reader gesture met Main's range accounting, and the reading position existed in six representations that had to agree. Main now keeps a tail cache and answers page requests pass-through. `loadBefore` / `loadAfter` return a page without touching the cache, `loadAround` and `loadLatest` return a reset snapshot, and catch-up evicts whole Turns from the oldest edge while always keeping the newest. The Renderer owns the window: it extends it by pixel bandwidth, trims it with `retain`, and re-opens the edge it trimmed as a gap. `hasOlder` / `hasNewer` are Host page cursors on an answer; the Renderer combines them with its own coverage, so paging reaching an end never means nothing exists outside the window. `navigationVersion` invalidates navigation only — Session id, replica generation and Host epoch stay independent identity checks. Overlay settlement follows the same rule. Main used to broadcast `completedOverlayMessageIds`, so a window retired an overlay whether or not it installed the durable row that replaced it. A window off the tail declines tail growth, so it deleted the overlay and dropped the body in the same batch and the Turn vanished from the reader's view. The catch-up broadcast also filtered its rows through Main's own tail residency, so a row installed and immediately evicted under budget never reached any window at all. Retirement is now the window's own inference — installing a durable row retires the overlay it settles — and the broadcast carries every row the catch-up read. The reading position is one representation: the authority publishes the Turn crossing the top of the scrollport, the prompt rail derives its tick from that instead of running its own observers, and the controller keeps a bookmark to re-anchor after a replica generation change. Behavior changes: opening a Session prefetches history until two viewports sit above the reader; tail growth marks read while any consumer is open, where it previously waited for the reader to have nothing newer. Supersedes #5147, which patched these symptoms at the old window authority. Closes #5163 Generated-by: Claude Code
d171fbf to
cd811e1
Compare
The suite asserted that paging works and stays bounded, and sampled the mounted count only once the range had settled. Neither says where the reader ended up while a page was installing, which is the whole of what #5163 reports. This probe reads every frame, and at each change of the mounted range compares a Turn present on both sides: with no input between two frames its document position must not move, so `Delta top + Delta scrollTop` is zero unless the boundary displaced the reader. Measured settled-to-settled rather than across the changing frames: the range passes through an intermediate commit that mounts far more Turns than it keeps, and scroll anchoring corrects after layout, so a reading taken inside the change reports a correction that never reached the screen. It fails on this branch. Pure trims land at 18px; every page install displaces the reader by about 1800px. Also stops asserting `data-search-highlight` after waiting for the jumped Turn to mount. That highlight clears itself 2.2s after the command lands, so the assertion fails whenever loading the page around the Turn takes longer than the flash - a 3s pass turning into an 18s timeout under load, reproduced 2 of 6 runs. The jump's landing place is read from the reading position instead, which does not expire. Restores the band-check guard removed in the previous commit. A controlled comparison over 6 runs each puts the flake at 2 of 6 without it and 3 of 6 with it, so the ablation that removed it rested on a single passing run and the guard is not what that flake was about. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
Automated review, posted at M4n5ter's request. Parallel deep-code-review and bounded simplify-audit, with independent adjudication. Reviewed bba473c27d9cc87c9cde51d0f68f68727ccd22e7 against merge base fa0ff028e14b96bc14d987f156a2ad79d8b45151; the late update was checked before submission.
Request changes: three Blocking findings and two Important findings are attached inline. Correctness verdict: not acceptable. Design verdict: acceptable — Renderer-owned presentation is a net simplification; these fixes do not require returning window ownership to Main. No additional structural simplification is requested.
Verification: 41 existing targeted tests passed (35 range-store/navigation-race + 6 scroll-hook), bundled from the affected sources. Diagnostic probes reproduced fragmented reconnect, extension-after-trim, and failed-prefetch retry. An additional pending-bookmark eviction probe failed on cd811e18 and passes with the guard restored in this head; that resolved issue is not reported as outstanding. Search cancellation was verified through the production call chain, and the new displacement oracle was checked algebraically. UI probes use synthetic geometry, not a real browser displacement trace. No production changes were made.
The latest required CI test check is red at the Electron E2E budget gate (3 recorded tests versus 4 in the spec). Full repository tests and the new browser trace were not independently run.
中文摘要
已包含提交前刚推送的增量,统一裁决后确认 3 项 Blocking、2 项 Important,逐项附在代码上。单一 Renderer 窗口 owner 的方向成立,没有值得追加的结构性简化;目前实现不能通过 correctness 审查。
41 个现有定向测试通过;重连、裁剪后旧扩页、失败重试三个诊断复现仍失败。已验证新提交修好了待定位书签被裁剪的问题,因此不再将它列为未解决项。另通过生产调用链确认预取取消搜索,并确认新增位移测试的公式不能区分正确与失效的滚动锚定。最新 CI 仍为红色。
…assumed The window has five writers — command answers, replica replacements, tail broadcasts, band trims and automatic fills — but only command answers were invalidatable, and the automatic fill borrowed the reader's own navigation channel. Five reported defects fall out of those two gaps. A read is answerable only while what it assumed still holds, and the two kinds of read assume different things. An extension splices rows onto one edge, so any replacement of that edge — navigating away, or the band trimming it out — makes its answer unable to reach what is left; installing it would open a hole the contiguous-window model cannot express and no edge cursor can name. A replacement discards the edges, so only a newer navigation makes it stale, and a replica replacement that answers nothing this window asked for is admitted whole rather than misfiltered as a superseded command. Filling an edge is not navigating to it: it gets its own channel, so it no longer consumes the reader's outstanding jump, and it answers whether the window moved so a read that changed nothing is not reissued in its own callback, forever. Generated-by: Claude Code
|
Thanks @M4n5ter — all five are fixed in aedc843, and each thread has the regression that fails without its fix. Rather than patch them one at a time I looked for what they share, and they collapse into two. The window had five writers and one of them could be invalidated. Command answers, replica replacements, tail broadcasts, band trims and automatic fills all write the window, but the only invalidation mechanism was a command version. So a replacement was misfiltered as a stale command answer (#1), and a trim bypassed invalidation entirely, letting an extension splice rows onto an edge that no longer existed (#3) — your The rule that covers all five writers is what each read assumed. An extension assumes one edge, so any replacement of that edge refuses it. A replacement assumes nothing about the edges, because it discards them, so only a newer navigation makes it stale. Batches that name no epoch answer nothing and apply to whatever the window holds. Promoting the counter this way removed a special case rather than adding one — the old Automatic filling had borrowed the reader's navigation channel. On your oracle comment (#5) — you were right, and that formula had already misled me once before you got here. Details and the limits of what the probe can prove are in that thread. Verified: 2426 |
|
@Astro-Han Thanks for pushing this through. Separating what an extension assumes from what a replacement assumes reads much clearer in aedc843, and the Renderer-owned window does look like the simpler model. I ran a few probes against aedc843 (the real observer, replica and Renderer store, with a fake Host), and three sequences still seem to leave the window somewhere the contiguous model can't recover from. I may be missing a guard, so please read these as questions. A fill during a pending jump can splice the old edge onto the new window. replaceWindow() mints the epoch when loadAround is called, but range() still returns the old window until the reset lands, so an extend() in that gap pairs the old anchor with the new epoch. The replica's #enqueue then answers the jump first and the fill right after it. Probe: tail 18..20, jump to 5, fill before the reset lands, result [5, 6, 17] with newest 17, so 7..16 can't be paged in. This looks like the search ordering from the earlier review, now that the fill no longer cancels the jump. A trim plus a fill during a pending jump makes Main drop the jump. retain() mints only a window epoch, but Main keeps a single consumer.windowEpoch, so the fill's higher epoch makes the in-flight loadAround fail isCurrent(). Probe: same setup, retain(19, 20) and then a fill. Both commands resolve without an error and the window stays at [18, 19, 20]. A Turn completing at the tail lands in a window parked far from it. loadAround's snapshot carries the live overlay, so a jump during a run shows [5, 6] plus overlay 21. When 21 completes it is inserted as durable, giving [5, 6, 21] with newest 21, and 7..20 are inside the range with no edge left to fetch them. The new settlement test checks Turn B's text but not its neighbours. In case it's useful, a couple of directions I could imagine, though you'll know better what fits: hold extensions until the outstanding navigation's reset commits (or mint a window epoch when it does) and keep navigation and window epochs separate in Main; for a parked window, update the overlay in place and move it into the durable range only once the window reaches it. Happy to share the probe files if that would help. |
The notice told the reader that the window they are reading does not hold the whole transcript, and gave them a button to extend it. Neither is theirs to care about: the window is a memory budget, the band already fills the edge a reader approaches, and every arrival and departure of the notice is a height change above or below them with no content behind it. It arrived in #4560 without a design decision — the merge's own before/after images do not show it — and #5147 has since had to exclude it from browser scroll anchoring, because a row that is not content was becoming the anchor the reader's position is measured from. Removing it retires that exclusion, the row-projection module whose only job was placing it, its copy in three locales, and the whole explicit history-load path it was the only entry point to: the one navigation a reader still makes is returning to the tail. Removing it also exposed a bug it had been hiding. Returning to the tail cancelled an outstanding bookmark frame only because the notice's pending state forced a render; without that render the queued frame scrolled the reader back to the bookmark they had just left. An explicit pin now outranks a queued restore, which is what the existing regression always claimed to check. Generated-by: Claude Code
…k inventory Removing the range boundary notice removed the `useState` that held its pending target; the gate counts call sites, so the inventory has to say 11. Generated-by: Claude Code
The removals in this branch move every recorded number down, except `app-shell-effects.ts`, which grew with the reading-position work. The retain callback's parameter was also named `window`, which shadows the global and which the scanner reads as an environment capability the shell does not use. Naming it for what it is keeps a phantom capability out of the ledger. Generated-by: Claude Code
…ipt apply is live `applyTranscript` and `applyReadError` each took an `isDisposed` closure so a stable event handler could re-check a flag its caller already knew. Both callers know it locally: the store subscription is torn down in the effect's cleanup, so anything it publishes is live by construction, and the two error paths either sit behind the batch callback's own early return or check the flag where they are. Removing the indirection takes the file below the debt the ledger recorded for it before this branch, so the architecture ratchet passes again. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
Automated targeted re-review at M4n5ter's request, against 327d96a89b43a8b77297eadecdefb3afe4f91c20. Ordinary review comments only.
The previous direct failures have been addressed: unsolicited recovery snapshots are unversioned throughout; trimmed-edge answers are refused; background filling no longer calls the explicit cancellation path; failed/no-progress filling stops reissuing itself; and the displacement oracle now uses viewport coordinates. The restored pending-target guard remains in place.
One correctness issue remains, attached inline: the new distinction between navigation lifetime and edge lifetime is not maintained through Main or all fragments of a replacement response. Two focused probes reproduce both paths. Not approving this revision yet. The Renderer-owned window direction remains acceptable; no additional independent simplification is requested.
Validation: 47 existing targeted tests passed (range store, navigation races, scroll hook, return-to-latest pin); 2 added diagnostic probes failed as described inline. Production source was unchanged. No full-repository suite or real-browser displacement run was performed independently. The latest CI failure is the stale generated docs/astryx-surface-file-inventory.md, separate from this runtime issue.
中文
上轮直接问题已逐项修复;本次剩 1 个 correctness 问题:导航与边缘维护虽然分出了不同生命周期,但 Main 和分批回复仍混用,已用两个定向测试复现,因此暂不 approve。
47 个现有定向测试通过,2 个诊断复现失败。单一 Renderer 窗口 owner 的方向成立,没有额外独立简化建议。CI 另因生成清单未更新失败;未独立运行完整仓库测试或真实浏览器位移测试。
At M4n5ter’s direction, withdraw the blocking review state and retain the review comments. The current follow-up is a COMMENT review; this is not an approval.
…w under it The window has two lifetimes, and the split stopped at the Renderer's `accepts()`. Both leaks let a navigation the reader asked for be dropped. A replacement admitted its reset batch under the navigation epoch and then failed its own continuation fragments against the window epoch a trim had minted meanwhile. The reset now takes the window's identity back to the epoch the navigation was issued under — it discards the edges those trims were protecting — so the rest of its fragments splice on. Minting moves to a counter kept apart from that identity, so a number still names one window forever and a page in flight under the trimmed one stays refusable. Main ordered by a single epoch and read any newer number as "the Renderer left that window", which an extension issued after a trim is not: it advanced the consumer and silently discarded the outstanding replacement, even a one-batch one. Only a replacing command now moves the consumer; an extension naming a newer window is admitted without moving it, and a read stays current while its epoch is at least the standing replacement's. Also refreshes the astryx surface inventory, stale since chat-view stopped reaching for HStack and Text with the range boundary notice. Generated-by: Claude Code
Letting an extension name a newer window without moving the consumer left the queued answers of the window it left behind in place: they held their bytes in the delivery budget until they were sent and refused, and the opening ramp mints an epoch per trim. A consumer could reach its delivery capacity on a transcript nothing was wrong with. Queued answers are dropped again, now by what the window can still take rather than by whichever number is newest: an extension splices onto the window it named, so any newer one strands it; a replacement installs its own window, so only a newer replacement does. The same question decides what the delivery loop sends, so a page cannot be kept and then discarded unsent. Sizes the scroll suite's settle wait against the opening ramp rather than the 10s default, which the suite documents as sized for UI already on screen. How many pages the ramp reads is how tall the viewport happens to be against the fixture; this machine finishes it in under two seconds and a loaded CI runner measured past ten. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
Automated targeted re-review at M4n5ter's request, against 5b8679e2978687207a8d90a67742904bdf0c2dff.
The previous two failure paths are fixed: a fragmented replacement survives trimming, and background filling no longer cancels that replacement. One follow-on correctness regression remains in the new delivery-budget cleanup: after that replacement succeeds, fresh pagination can be silently discarded. See the inline comment; not approving this revision yet.
Validation: 49 existing targeted tests passed. An additional probe using the actual range controller, observer, encoder and store confirms the replacement completes, but then fails when loading its next page. No production code was changed. The Renderer-owned window direction remains acceptable; no separate simplification is requested.
The latest CI test check also fails the new scroll-boundary E2E assertion (reported displacement 120px versus the 40px limit; 37 other E2E tests passed). I have not established whether that failure is product motion or measurement/input timing, and am not classifying it as a flake. Full repository and browser suites were not independently rerun.
中文
上次两个失败路径已修复,49 个现有定向测试通过。但新增预算清理逻辑会在导航成功后丢弃新的有效分页;实际 controller/observer/store 串联复现失败,因此暂不 approve。
CI 另在滚动边界 E2E 中报告 120px 位移、超过 40px 阈值;尚未确定是产品位移还是测量/输入时序问题,不能直接视为 flaky。单一 Renderer 窗口方向仍可接受,无独立简化建议。
…ants Three rounds of epoch patches had left the window with no statable rule about when a read may be spliced onto it. Adversarial review found two ways to break it, both of them mine, and both dissolve once the lifecycle is named. The window is in one of two states. Settled: its rows are the window its identity names, so an edge can be read from and its answer spliced back. Navigating: a replacement has claimed the window but its rows have not arrived, so the edges on screen belong to the window being left. I1, the window is always contiguous. A read is answerable only while the edge it was anchored on is still the window's edge. An extension may therefore only be issued while settled, and carries the window's identity; a replacement carries its own, and all of its fragments belong to it however many epochs the band mints underneath. Installing a replacement names the window afresh, which refuses every read that was anchored on an edge it threw away. Filling while navigating anchored on the window being left and spliced onto the one that replaced it — a hole no edge cursor can name and no later page can fill. Now there is nothing to anchor on, so nothing is asked. I2, a fill does not spin. A read that left an edge exactly where it found it answers the same way again, so the range keeps where each edge stood and refuses to re-ask until something moves it. This replaces the `moved` answer `prefetchHistory` reported back to the scroll hook, which asked the geometry to decide whether a read was worth issuing: it read the store's snapshot identity, which a Session streaming into its tail changes on its own, so the guard was blind exactly when it was needed. Deciding whether a read is worth issuing belongs to the layer holding the window. Main keeps a single number again. Which window the Renderer holds is not derivable from the epochs Main sees and does not need to be — the Renderer refuses what it cannot splice. The monotone `newestWindowEpoch` of the previous commit claimed to derive it and got it wrong: once a navigation's answer landed it stranded every later fill, leaving the older edge unable to load for the rest of the Session. Also drops the gap-row assertion the perf suite still made against a row deleted with the range boundary notice, and the two dead gap measurements beside it. Generated-by: Claude Code
…on the wheel The probe skipped frames within 250ms of the last wheel event, on the theory that a reading which catches a tick cannot tell the reader's own scrolling from a page that moved them. The gesture and the rAF that reads it land in the same frame in an order nothing here controls, so on a loaded runner the reading went first and the guard was still holding the previous gesture's timestamp: eight boundaries reported displacement of exactly 120px, one wheel tick, where this machine reported none. A time window cannot fix that, because the quantity it is inferring — whether the reader is mid-gesture — is something the test already knows. It now says so: the measurement window closes around each gesture and opens for the quiet that follows, which is where a page lands and where a reader would see it jump. Generated-by: Claude Code
Throwaway drivers for investigating a scenario by hand have to sit beside the suite to resolve `@playwright/test`, and four of them reached a commit here, where the ASF header audit correctly refused them. Generated-by: Claude Code
|
@Totoro-qaq Hi, the probe files would be indeed very useful! It would be great if you could share it on this PR discussion. |
Two stories still answered `onPrefetchHistory` with whether the window moved, which the range controller now decides for itself. Generated-by: Claude Code
|
@Astro-Han Thanks, here they are. I re-ran them on 15894f8: the first two now pass, and your new regression tests look like they cover the same sequences. The third still reproduces. Both files follow the layout of On 15894f8: On aedc843 the two jump tests failed as described earlier: the window ended at For the third one I wasn't sure what the intended behaviour is, since the settlement test wants the completed body to stay visible. So the assertion only checks that the durable range stays
|
|
A suggestion on the overlay handling. I don't think this is a missing guard — a durable row shouldn't go on the axis at all while the window is parked.
The retirement rule is right; it's the install half that shouldn't run here. The body is still useful — it's the settled text of a message this window is showing, so it can refresh the overlay record in place without ever taking a sequence. Element does exactly this on remote echo (same object mutated; const overlaid = this.#overlay.has(message.id);
if (!installDurable && !overlaid) return false;
const sequence = pending.identity as number;
+ if (!installDurable) {
+ // Settled text for a message this window is showing, but the window
+ // does not reach the row's place on the axis. Refresh the overlay in
+ // place; installing it would open a hole no edge cursor can name.
+ const settled = this.#overlay.get(message.id)!;
+ this.#overlay.set(message.id, { message, encoded: projected, order: settled.order });
+ this.#declinedSequence = Math.max(this.#declinedSequence ?? sequence, sequence);
+ return true;
+ }
const existing = this.#durable.get(sequence);
Two loose ends: Probe 3 from the thread works as the regression as-is. |
…value The Renderer owned the window but kept Main's mutable structure without the serial queue that had made it consistent. Four writers — jump, fill, trim and tail broadcast — mutated it in place across processes and rAF frames, and a monotone window epoch stood in for the transaction the structure never had. Three holes followed from that shape, each found independently by review and by Totoro-qaq's probes: a tail change lost in Main's delivery loop left the window believing it still reached the tail, so the next row spliced onto a gap; `#reset()` forgot to mint and left `#navigating` set; and a jump snapshot carried the whole overlay into a parked window, whose settlement then installed the durable row far from anything it held. Durable sequences advance by a stride, so adjacency cannot be read off the numbers. The only proof that rows are contiguous is the Host read that produced them, anchored on a known row or watermark. The window is now an immutable value and every answer carries its anchor: a page names the edge it was read from (`extends`), a tail change names the watermark it read forward from (`coversFrom`), and a reset names the navigation it answers. Rows land only where the anchor is still the window's edge; watermark and overlay retirement land regardless, because they are facts about the tail, not the window. Batches assemble off-window and install once at `ready`, so the screen never shows half an answer and no navigating phase or fill barrier is needed. `coversFrom` has three states: a number or `null` (read from the start of the transcript) is a contiguity claim; absent is none. Main's merge drops rows and clears the claim when consecutive changes do not meet, and the delivery loop no longer cuts an answer short mid-stream. A dropped increment therefore degrades to a watermark the window cannot join, which sets `hasNewer` and lets the band read forward from its own edge — Main's navigation number is now only the cancellation hint the old comment claimed it was. The overlay leaves the window. It is shown only while the window reaches the tail, which retires both entrances the escape hatch had: a jump snapshot's overlay in a parked window, and a trim that opened the newer edge without dropping it. The parked-window settlement test encoded that hatch and is rewritten to the new contract; Totoro-qaq's probes are adopted as regressions. Fills report whether they issued a read, and the scroll hook re-checks only when one did: the previous "cannot spin" claim was false, since a refused fill resolved synchronously and re-entered the check. Ablation considered and rejected: pull-only tail delivery (watermark without rows) would flip `hasNewer` on every Turn completion and hide the streaming overlay for a round trip each time. The push stays, verified. Generated-by: Claude Code
`app-shell.tsx` gained one token: the prefetch fallback now resolves to `false` so the scroll hook knows no read was issued. Generated-by: Claude Code
|
@Totoro-qaq Both probe files are adopted as-is in On the third one — you were right to leave the intended behaviour open, because the settlement test was asserting the hole. The rule is now: the overlay is a fact about the tail, not a member of the window, so a window that no longer reaches the tail does not show it; when the Turn completes, the durable range stays whatever the band trimmed it to ( The underlying change: every answer now carries the anchor it was read from, and the window installs it only where that anchor is still its edge. The PR body's "Scope of the staleness mechanism" describes it. |
`loadLatest` answered from Main's tail cache. Global memory reclaim can trim that cache to nothing while the Session stays open, so returning to the latest messages could install an empty window with `hasNewer` false — a blank conversation, and no affordance left, until the band's older prefetch refilled it. On `main` the follow-tail command read a fresh page from the Host. The tail is read back before it is replayed: when the cache holds fewer Turns than it should and older rows exist, `loadTranscriptLatest` reads the newest page from the Host into the cache, then answers through the existing reset path with the watermark the cache already names. A cache that covers the whole transcript is not short, so a small Session still answers without a read. Reclaim keeps trimming; it is a performance event again, not a correctness one. Generated-by: Claude Code
Sequences name the same rows only within one Host epoch, so the generation-change re-anchor stopped at an epoch change and a reader parked in history was dropped at the tail when the Runtime Host restarted. On `main` the registry rewrote the outstanding navigation by Turn id. The Renderer now does the equivalent with what it already has: after an epoch change it refreshes the Turn landmark index, resolves the bookmarked Turn id to its new sequence and navigates there; a Turn the new index does not know leaves the reader at the tail, quietly. A read still in flight across that change was rejected by Main as a stale epoch, and the rejection reached the conversation as a load error for a condition the reset that follows resolves by itself. The recovering controller now raises that rejection as a superseded read; the reading-position layer treats it as neither an error nor an unavailable bookmark, so the banner does not appear and the bookmark survives for the re-anchor above. Every other error is still raised. Generated-by: Claude Code
WorkHub mounts the same ChatView as the chat surface but was wired without the band: no automatic fill, no trim, an explicit "older" button instead. Two consequences of the Renderer-owned window followed. Every page the button loaded was retained forever, where Main used to bound every consumer's window. And a window behind the tail never followed it — Main used to splice tail growth into whatever window it held — so a queued follow-up's own row never arrived, the observation check never satisfied, and the next follow-up was refused as a retry. The WorkHub transcript port now carries `prefetchHistory` and `retain`, delegated to the same range controller the chat surface uses, and the ChatView gets the band's callbacks. The "older" button goes with it. Queuing a follow-up returns the window to the tail before admission, on every admission outcome, so the queued row lands and the guard clears. A trim publishes the trimmed snapshot: it is the one window change with no batch behind it. Generated-by: Claude Code
|
Not a correctness review — the three blocking findings above are the ones that matter, and I have nothing to add to them. This is only a pass for dead weight, checked against 1. An orphaned doc comment that is now wrong
/** The one-byte read `loadAround` uses to ask whether `sequence` has anything
* older than it: only the presence of a fragment answers, not its content. */
function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> {Pre-PR this sat above 2.
|
me2seeks
left a comment
There was a problem hiding this comment.
The return-to-latest refill and WorkHub pixel-band changes are sound. One failure boundary remains in the new cross-epoch bookmark recovery; attached inline.
中文摘要
新增的尾部回读和 WorkHub 像素窗口修复成立;跨 epoch 书签恢复仍会在一次 landmark 查询失败后永久放弃。| if (!landmark) return; | ||
| props.sessionUi.setTranscriptReadingAnchor(sessionId, { turnId, sequence: landmark.sequence }); | ||
| navigate(landmark.sequence); | ||
| }, () => undefined); |
There was a problem hiding this comment.
Blocking — keep cross-epoch re-anchoring retryable when the landmark read fails.
This rejection is reachable on the exact Host-recovery path this commit fixes, but it is silently consumed after line 192 has already advanced lastLiveGeneration to the replacement generation. Any later render for the same generation returns at line 193, so neither a subsequent message update nor the separate landmark-index refresh can retry the bookmark lookup. A single transient sessions:listTurnLandmarks failure therefore leaves a reader who was parked in history at the reset tail for the rest of that Host generation, with no error and no unavailable indication.
Keep a pending re-anchor keyed by Session/generation until the lookup succeeds or definitively reports that the Turn is absent, and retry it from the refreshed index or a later effect pass. A focused regression can reject the first landmark read, then make it succeed and rerender/update the index; the controller should eventually call loadAround for the bookmarked Turn rather than treating the epoch as handled.
中文
这次查询失败发生在本提交要修复的 Host 恢复路径上,但失败被静默吞掉前,`lastLiveGeneration` 已经推进到新 generation。之后同一 generation 的任何 render 都会提前返回,独立的 landmark index 刷新也不会再触发定位。因此一次瞬时 RPC 失败会让历史区读者在整个 Host generation 内永久停在 tail,既不重试也不报错。应保留按 Session/generation 标识的 pending re-anchor,直到查询成功或明确确认 Turn 不存在。There was a problem hiding this comment.
Retracting this finding after tracing the production IPC policy. The code would indeed abandon the re-anchor if an arbitrary landmark Promise rejected, but the recovery-time transient failures I cited (connection_lost and Host draining) do not reach this branch: sessions:listTurnLandmarks is registered through handleReconnectableRead, whose router waits for the replacement handler and retries the query. I did not find a concrete, recoverable production failure left over that makes this a valuable blocker; an injected generic rejection alone is insufficient evidence. 撤回:恢复期可重试错误已在 Main 层处理,这条评论没有建立真实可达且有独立用户影响的剩余场景。
me2seeks
left a comment
There was a problem hiding this comment.
Reviewed the three commits added after the prior approval. Tail refill after global cache reclaim, cross-epoch Turn re-anchoring, and WorkHub pixel-band ownership are coherent with the Renderer-owned window and have focused regressions. I retracted the landmark-retry comment after confirming recovery-time transient read failures are retried below this layer by the reconnectable IPC router. Required CI passes.
中文摘要
新增三个提交复审通过:缓存回收后的尾部回读、跨 Host epoch 的 Turn 重定位、WorkHub 像素窗口接入均与单一 Renderer window owner 一致并有定向回归。landmark 重试意见经 IPC 恢复策略核查后撤回;CI 已通过。| acknowledged = through; | ||
| void (async () => { | ||
| try { | ||
| await (await current()).acknowledgeTail(through); |
There was a problem hiding this comment.
Could tail acknowledgement be opt-in for a visible transcript reader? This generic controller is also used by readDelegatedTurnResult() in create-workhub-services.ts, which opens a transient consumer only to compute a bounded WorkHub result preview; it never renders that transcript. That consumer can reach the tail here and cause Main to advance the target Session read marker. If the delegated Turn has later durable rows, the helper can return only the target preview while acknowledging the later tail and clearing its unread state. Please disable acknowledgement for this projection, or require explicit visible-reader opt-in, with a regression that delegationFeedback does not advance the target Session read marker.
There was a problem hiding this comment.
Good catch — fixed in 0a881c46c. Tail acknowledgement is no longer a property of the generic controller: createDesktopTranscriptRangeController stays silent unless the caller passes acknowledgesTail, and the only production caller that does is createRecoveringDesktopTranscriptRangeController, which every visible transcript reader (the app-shell conversation and the live WorkHub transcript) uses and no projection does — so readDelegatedTurnResult(), on the plain controller, can never move the target Session read marker. The projection never even needed to reach the tail: Main's first reset is replica.snapshot() with hasNewer: false, so it was the very first ready() that acknowledged. I enumerated every opener of a transcript consumer — four in total, two visible readers and two projections, the other projection (session-message-settlement) opening the raw handle and never having had a controller at all — so no other projection can acknowledge either. The regression is delegation feedback does not advance the target Session read marker in workhub-coordination-transcript-preload.test.ts: it drives delegationFeedback against a handle that records acknowledgeTail calls, with durable rows from a later Turn beyond the delegated one, and asserts the preview still comes back while nothing is acknowledged. Reverting the fix makes it fail with actual: [2].
me2seeks
left a comment
There was a problem hiding this comment.
MicroGery’s finding is valid and should block this head. delegationFeedback() calls readDelegatedTurnResult() as a background projection; that helper opens createDesktopTranscriptRangeController, whose subscription automatically acknowledges any tail snapshot it reaches. No transcript reader is mounted, and the returned preview can cover only the delegated Turn while later durable Turns remain unseen, yet the acknowledgement clears the target Session’s unread state through the latest visible message. Tail acknowledgement therefore needs explicit visible-reader ownership (or must be disabled for this projection helper), with the regression MicroGery requested. I am not duplicating the inline finding.
中文摘要
MicroGery 的意见成立:后台委托结果预览复用了会自动 ACK tail 的可见阅读 controller,在未展示目标 Session、甚至只读取较早委托 Turn 时也可能清除更晚消息的未读状态。应让 tail ACK 由可见 reader 显式启用,或在 projection helper 中禁用,并补回归。Three mirrors of state that already lives somewhere else, each written
and never read back.
`PendingTranscriptPage.replaces` was queued alongside every page answer.
Its strongest case was that it names the same thing as
`#admitTranscriptNavigation(request, targetId, replaces)`, so it reads
like that concept persisted; but that argument is an input consumed at
the call site, and the delivery loop only ever looks at `navigation` and
`generation`.
`DesktopTranscriptReplicaSnapshot.navigation` was a Renderer concept on
a Main type the replica never sets: the only writers were the observer's
two `{ ...snapshot, navigation }` spreads. It looked load-bearing because
`encodeDesktopTranscriptSnapshot` needs a navigation, but what the
encoder needs is `TranscriptBatchIdentity`'s field, which a second
parameter supplies. The field also reached the local transcript cache,
which stored it permanently undefined.
The controller's `lastNavigation` was identical to the store's
`#navigations` by construction — same initial value, incremented only in
`navigate()`. The extension path needs "the navigation the window sits
on" once a pending answer has landed, but that is the store's own state;
it now answers for it through a one-line `navigation()` getter.
Generated-by: Claude Code
…e's commit WorkHub delivered window changes over a second, hand-maintained path: a `handler(store.snapshot())` inside the batch callback, plus a second one inside `retain` guarded by a `trimmed` flag, with a comment explaining that a trim is the one window change nothing else delivers. That comment was the tell — the store already has a single channel that covers both entry points, `#commit()` behind `subscribe()`, which is how app-shell has always been wired. Two hosts of the same store were kept consistent by hand instead of by construction. `store.subscribe(() => handler(store.snapshot()))` puts the callback shape the port wants on top of that one channel, so the trim special case and its `cancellation.aborted` re-check go with it. The subscription is torn down both on `close()` and on the cancellation signal, which is what made the extra guard redundant. It is also strictly safer: the old `|| batch.ready` branch could call `store.snapshot()` before any reset had installed a range, where `range()` throws. The two `if (!store.accepts(batch)) return;` guards in front of `store.accept(batch)` went too, and `accepts` is now private. `accept()` calls it first and returns false without decoding anything, so the guard only ran the same test twice. It did not even cover the one case where the two differ — a reset for another Session, which `accept()` throws on and `accepts` admits. Generated-by: Claude Code
`consumer.acknowledgedThrough` mirrored, per consumer, a deduplication the Renderer already does: the window reports a watermark once, and only while it is actually at the tail. The case for keeping it was that the Renderer is not trusted, so a repeated acknowledgement would re-write the read marker. It does not hold. The write is idempotent twice over — `#markTranscriptRead` goes to `setSessionReadMarker`, and the Host's catalog coordinator declines a marker equal to the one it already holds. The field was per consumer, so two windows on one Session each acknowledged and each triggered a marker anyway; it never bought "once per Session". And a watermark that goes backwards was never its job: `request.through < durableThrough` rejects that, and stays. Generated-by: Claude Code
Main threw `'Desktop transcript host epoch changed; reopen the
transcript'` and the Renderer recognised it with
`message.includes('Desktop transcript host epoch changed')` — the same
literal written twice, on either side of `ipcRenderer.invoke`, where
editing the prose on one side silently reclassifies the rejection on the
other.
The reason given for leaving it as prose is that invoke carries only a
message and no type channel. True, and already solved elsewhere in this
app: `SESSION_WORKSPACE_UNAVAILABLE_CODE` is an exported constant that
both the thrower and the matcher name, matched as a `CODE:` message
prefix. `DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE` in
`preload/transcript-contract.ts` does the same for this one; both sides
already import that module.
`TranscriptReadSupersededError` stays. Making Main silently no-op
instead is not equivalent: `restoreSessionTranscriptRange` reads
"succeeded but the Turn is not in the window" as a dead bookmark and
clears the anchor, which would break re-anchoring across an epoch
change. Superseded has to remain a distinguishable outcome.
Generated-by: Claude Code
`subscribeToReaderScroll` broadcast `(direction, phase)`. Before this branch the direction had a real consumer: `use-chat-scroll` decided whether to request history with `canLoad(direction) && nearEdge(direction)`. Moving that decision into the pixel band's own geometry left both production subscribers ignoring the parameter — one takes only `phase`, the other takes nothing — with the argument surviving in the contract, in `reportReader`, and in a story. The authority does still compute a direction, which is the case for keeping it. But that direction serves the pin decision at scrollend; it is not something a subscriber was using, and a subscriber that wanted one could recompute it from geometry, as the band now does. Generated-by: Claude Code
The transcript window work added `acknowledgeTranscriptTail` and `loadTranscriptLatest` to the source `openTranscript` requires, so the teardown fakes landed on main with five of the seven methods. Against this branch `requireTranscriptSource` rejects them, `openTranscript` throws before the fake ever runs, and the `started` deferred these tests await never resolves — the file hangs to the 900s timeout instead of failing. Generated-by: Claude Code
…ilable openTranscript registers the consumer, then called requireTranscriptSource outside the try. A source missing any transcript method therefore threw past the cleanup every other failure takes: the registration stayed in the map and its `ready` promise never settled, so the consumer id was permanently burned and anything awaiting readiness hung. #restoreTranscript already made the check inside its try; this makes the open path agree. Regression: `releases a transcript registration whose source lacks the window contract` attaches an observe-only source and asserts both that the open rejects and that the registration is gone — reusing the consumer id reaches the same failure instead of the duplicate-identity guard. Verified to fail with the check moved back out. Generated-by: Claude Code
…-through props A one-field interface around a sequence number bought nothing: the preload unwrapped it on the IPC boundary and both the wire request and the batch payload already declared `navigation` bare, so the wrapper existed only to be built and taken apart again. Pass the number. ChatMessageSurface re-declared and re-passed four ChatView props that its own props type already inherits and `...chatViewRest` already forwards. Removing the duplication keeps them required by naming them through `Required<Pick<…>>` rather than relying on the surface's own optionality. Generated-by: Claude Code
The transcript range controller wired tail acknowledgement to every consumer that reached the tail. readDelegatedTurnResult() opens one only to compute a bounded WorkHub result preview and never renders it, so a delegated Turn with later durable rows had its Session marked read — returning the target preview while clearing unread state nobody saw. Acknowledgement is now an explicit property of a reader that renders the transcript: the plain controller stays silent unless asked, and the recovering controller — which every visible reader uses and no projection does — is the single place that asks. The projection cannot opt in by accident. Regression: `delegation feedback does not advance the target Session read marker` drives delegationFeedback against a handle that records acknowledgeTail, with a later Turn beyond the delegated one. Generated-by: Claude Code
Comments that restate the code they sit on, pin a number nothing re-verifies, or repeat a sentence the contract already carries do not survive the next edit intact, and a stale one is worse than none. Keep each statement where the contract lives and delete the copies. The parked-completion probe asserted only what the range-store suite already proves; its live overlay row now carries an assertion of its own — verified to fail both when the coversFrom check is dropped and when the overlay settle is. Also: e2e assertions against selectors no product code emits, a local `deferred` the shared test-only helper replaces, a private method that only forwarded to a generic one, and a probe value threaded through ten sites to feed an assertion that proved nothing. Generated-by: Claude Code
|
Thanks for the two retractions — a follow-up audit of every acknowledgement path reached the same conclusions on both. The Renderer-side dedupe is still keyed by the sequence alone; the one case it can swallow is a Host replacement whose tail lands on the same number, which leaves that Session's unread state as it was until the next message, so it is recorded and left as is. The landmark read goes through |
|
Thanks for the pass — all five items plus the nits are addressed.
Two deliberate keeps, both in
On item 3, the new overlay assertion was ablated both ways — it fails when the |
…enderer-window apps/desktop/renderer-architecture.json: generated ledger. Took either side, then regenerated with `check-renderer-architecture.mjs --write`; the app-shell entry settles at the merged source's own count rather than either parent's. Verified with `npm run check:architecture` and with `--base origin/main`, the convention CI uses. packages/ui/src/__tests__/prompt-anchor-rail.test.ts: main deleted the static-markup landmark test here and reintroduced it as a mounted portal test in the sibling file; this branch had instead rewritten it to render under the scroll authority. Took main's deletion, since the mounted version covers the same landmarks through the real layout. Kept this branch's `selectPromptRailTick` import and dropped main's `selectPromptRailActiveTurn`, `selectPromptRailTickForMountedTurn` and `PromptRailFrameScheduler` — this branch deleted all three with the rail's own observer machinery — along with the now-unused `TranscriptScrollAuthorityProvider` import. packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx: took main's `PromptAnchorRail` import and dropped `READING_BAND_TOP_PERCENT`, which no longer exists — this branch deleted the reading-band observer it belonged to. Main's ported portal test rendered the rail bare to assert no inline rail exists before a host does; under this branch the rail reads its tick from the scroll authority and throws without one, so that first render now happens inside `TranscriptScrollAuthorityProvider`. The assertion it makes is unchanged: host absence, not authority absence, is what leaves the rail unrendered. Semantic conflicts, auto-merged but resolved by hand afterwards: apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts: #5188 bounds a sparse transcript continuation at its client range boundary, so an oversized Turn now fills a range on its own and a reset anchored on the oldest row no longer reaches the tail in one page. The test's invariant is reachability, not page count, so it now asserts the reset ends at the boundary with a newer edge and that the page behind that edge reaches the tail. Both intents kept: #5188's bound and this branch's one-page window read. Read against this branch's intent and left as auto-merged: app-shell.tsx (main's WorkHub return button, shared `sessionsSelected` predicate and single-pending interaction hydration fence sit beside this branch's transcript window props), features/conversation/index.ts, chat-message.css (main reworked sticky activity headers; this branch's transient-row `overflow-anchor` and gap-row removal are disjoint), e2e-budget.json, docs/astryx-surface-file-inventory.md, chat-surface-layout.tsx and chat-view.tsx (the rail host provider nests inside the scroll authority, so the portaled rail still resolves both contexts). Verified on the merge: desktop typecheck, `check:architecture` (plain and `--base origin/main`), root `format:check` and `lint`, `apps/desktop` test:dist (2492 pass) and `packages/ui` test:dist (419 pass), plus `build-storybook` and `smoke:storybook` (333 stories, 360 theme renders). `@maka/mcp` test:dist fails `user wait does not consume the network timeout` reproducibly; every input to that package is byte-identical to origin/main here, so it is inherited rather than introduced. Generated-by: Claude Code
Summary
Main owned the presentation window — a resident range with reading anchors, navigation versions, overlay settlement across pages and eviction bookkeeping — while the Renderer kept its own scroll state on top. Two owners of one window meant every reader gesture met Main's range accounting, and the reading position existed in six representations that all had to agree.
Main now keeps a tail cache and answers page requests pass-through.
loadBefore/loadAfterreturn a page without touching the cache,loadAroundandloadLatestreturn a reset snapshot, and catch-up evicts whole Turns from the oldest edge while always keeping the newest Turn. The Renderer owns the window: it extends it by pixel bandwidth and trims it withretain.This resolves the three contracts raised on #5163:
completedOverlayMessageIds, so a window retired an overlay whether or not it installed the durable row replacing it. A window off the tail declines tail growth — so it deleted the overlay and dropped the body in the same batch, and the Turn vanished from the reader's view. The catch-up broadcast also filtered its rows through Main's own tail residency, so a row installed and immediately evicted under budget reached no window at all. The overlay is now a fact about the tail, not a member of the window: the view shows it only while the window reaches the tail, a durable row retires it on sight whether or not the window keeps the row, and the broadcast carries every row the catch-up read.hasOlder/hasNewerare transport-level cursors on a Host answer. The Renderer combines them with its own coverage: an edge it trimmed to meet its budget is reachable again on the spot, so paging reaching an end never implies nothing exists outside the window.extends), a tail change names the watermark it read forward from (coversFrom), a reset names the navigation it answers — and the window is an immutable value that installs an answer only where that anchor is still its edge. A trim needs no announcement: the anchor no longer matches, and the answer is refused on arrival. Batches assemble off-window and install once atready, so the screen never shows half an answer and no fill barrier is needed while a jump is in flight. Main's merge drops rows and clearscoversFromwhen consecutive changes do not meet, so a lost increment degrades to a watermark the window cannot join — it setshasNewerand the band reads forward from its own edge. Main's navigation number is only a cancellation hint; the system is correct without it.The reading position is now one representation: the scroll authority publishes the Turn crossing the top of the scrollport, the prompt rail derives its tick from that instead of running its own IntersectionObserver / MutationObserver / rAF loop, and the controller keeps a bookmark to re-anchor after a replica generation change.
Automatic filling has its own channel. It had been borrowing the reader's navigation channel, which cancels restoration and clears the search target — right for a reader who decided where to be, wrong for a band that decided nothing — and which swallows errors, so a fill could not tell success from failure. It now reports whether the window moved, and a read that moved nothing (failed, or refused as stale) is not reissued until the reader or the range moves.
Supersedes #5147, which patched these symptoms at the old window authority.
Closes #5163
The range boundary notice is gone
The
上方还有未加载的较早消息 / 下方还有未加载的较新消息rows and their Load earlier / Load newer buttons are removed.They told the reader that the window they are reading does not hold the whole transcript, and gave them a button to extend it. Neither is theirs to care about once the Renderer owns the window: it is a memory budget, the band already fills the edge a reader approaches, and every arrival and departure of the notice is a height change above or below the reader with no content behind it. It arrived in #4560 without a design decision — the merge's own before/after images do not show it — and #5147 had to exclude it from browser scroll anchoring, because a row that is not content was becoming the anchor the reader's position is measured from.
Light only: the storybook harness here does not switch themes from the CLI, and every rule removed is theme-agnostic — the gap row's width, margin and padding, plus its
overflow-anchor: noneexclusion.Removing it retires that exclusion, the row-projection module whose only job was placing the two rows among the Turns, the copy in three locales, and the explicit history-load path they were the only entry point to:
loadHistory('earlier' | 'later'),historyLoadPendingand the pending plumbing behind it. The one navigation a reader still makes is returning to the tail, nowreturnToLatest().It was also hiding a bug. Returning to the tail cancelled an outstanding bookmark frame only because the notice's pending state forced a render; without that render the queued frame scrolled the reader back to the bookmark they had just left. An explicit tail pin now outranks a queued restore, which is what
the return-to-latest button consumes a pending bookmark framealways claimed to check — it fails without the fix.Behavior change
The other consumers of Main's window
Review found the read marker still reading
!change.hasNewer— a fact Main derived from the window it no longer owns. That is an incomplete migration rather than a single missing branch, so every other consumer of the window facts Main used to own was enumerated and checked for whether it still has a source. Five follow-ups:bf3b56ffe— the read marker moves only from an explicit Renderer acknowledgement of the watermark its window reached, and only once that watermark is the replica's own.5ca912757—loadLatestreads the tail back from the Host when global reclaim has emptied Main's cache, instead of replaying an empty snapshot as the whole transcript.368ac3d38— the reading position survives a Host epoch change, resolved through the Turn landmark index; a read in flight across that change is superseded rather than surfaced as a load error.b239a9b39— the WorkHub transcript gets the pixel band (prefetchHistory/retainon its port), so its window is bounded and a queued follow-up returns the window to the tail.16191d20f— the band re-checks on viewport resize, observing the root's size directly: the authority publishes only on snapshot change, and a resize that keeps the pin and the reading Turn publishes nothing.0a881c46c— tail acknowledgement is opt-in for a reader that renders the transcript;readDelegatedTurnResultcomputes a WorkHub preview through the same controller and never renders, yet its firstready()acknowledged the tail and cleared the target Session's unread state (MicroGery's finding). Only the recovering controller, which every visible reader uses and no projection does, opts in.Two audited items are deliberately unfixed: an unanswered edge read poisoning that edge, for which no reachable path was found with a consumer open, and the rail jump's destination hold, covered by the browser's own anchoring and the page-install displacement probe.
A simplification pass over the PR's own surface followed (
ae1a52d71..16dfa837e): a queued page's unreadreplaces, anavigationfield on the replica snapshot the replica never wrote, a controller mirror of the store's navigation counter, a publicaccepts()that ran the same test twice, thedirectionargument ofsubscribeToReaderScrollonce the band computed direction from geometry, a per-consumer acknowledgement dedupe in Main that mirrored the Renderer's, WorkHub's second snapshot publication path (it now subscribes to the store's commit), and a duplicated epoch-changed message literal (now one exported code).A dead-weight pass from review (
4bce9ecb4,4ee559739) removed the one-fieldDesktopTranscriptNavigationwrapper, four propsChatMessageSurfacere-declared and re-forwarded, comments that restated code or pinned numbers nothing re-verifies, e2e assertions against selectors no product code emits, and gave the parked-completion probe the overlay assertion that justifies it.1c1c3ff1ecloses a leak the merge with #4562 exposed:openTranscriptrejected a source missing the window contract outside its cleanup path, leaving the registration and itsreadypromise behind.Verification
Totoro-qaq's probes (adopted as
transcript-pending-jump-probe.test.tsandtranscript-parked-completion-probe.test.ts) reproduced the three holes on earlier heads of this branch —[5, 6, 17], a lost jump target, and[5, 6, 21]— and pass on the anchored window. The parked-window settlement test (transcript-overlay-settlement.test.ts) used to assert the third hole as the intended behaviour and is rewritten: after the band trims the newer edge the overlay is no longer shown, the durable range stays what it was trimmed to when the Turn completes, and reading forward from the edge brings the completed body back.Each fix carries a regression that fails without it, verified by ablation:
a fill issued while a jump is pending does not splice the old edge onto the new window[5, 6]a page anchored on an edge the band has since dropped is refused[18, 19, 20]a Turn completing at the tail does not splice into a window parked far from itcoversFromcheck dropped — row 21 splices onto[5, 6]tail rows a window cannot join are dropped, and its mismatched watermark still movescoversFromcheck dropped — row 9 splices onto[1]a fill is issued once per window and again as soon as the window movesspentnot compared to the window — a second identical reada fill that left an edge where it found it is not asked againspentnot compared to the window — a second identical reada failed fill is not reissued until the reader moves againfilling an edge leaves an outstanding jump alonethe return-to-latest button consumes a pending bookmark framemoves the read marker only as far as the Renderer window reports reachingreports each tail the window reaches once, and none while it is parkeda viewport that grows fills the band it just widened, without a reader gesturea viewport that shrinks trims what it just pushed beyond the banddelegation feedback does not advance the target Session read markerreleases a transcript registration whose source lacks the window contractsession-1stays registeredpartial-history-notice.spec.tswas flaking at 3/6 when this PR opened. Both of its failure points turned out to be real product behaviour rather than test fragility: adata-search-highlightassertion racing that highlight's own 2.2s expiry, and — after the trim fix — a refused answer being reissued from the fill's own callback, which turned the spec from 3.4s into a 1.3m fetch loop. It now runs green under repeats.Ablation removed one guard this refactor had introduced. The band-check pending guard was put back: a controlled run showed 2/6 failures without it against 3/6 with it, which does not clear it, and the original ablation had rested on a single passing run.
Verification not run
The full repository suite.
AI use
Select exactly one:
Tool(s) and scope: Claude Code — implementation, test authoring and verification across Main, preload and Renderer, under human review.
Checklist
Does this PR entail a change in behavior?