Repository navigation
fix(dispatch): unbind a row when its project leaves live state - #1087
Conversation
Fixes #863. `scrubGridRowMetadata` ran only at the persistence boundary, so it cleaned the copy autosave writes and not the state the app runs on. For the rest of the session a Grid Dispatch row could stay bound to a project that no longer existed: - the row's index filters to a project with no agents, so it is blank; - New Agent from one of its empty lanes resolves the dead tab, hits `if (!tab) return null` in `createDetachedDispatchAgent`, and fails with NO feedback — the placement overlay stays open, because only a successful spawn closes it, and the user has to guess that Escape is the way out. `workspaceWithoutSessions` is the one place a project leaves live state, so it is the one place that has to say so. The scrub runs on EVERY commit rather than only when a project left: the same helper also prunes `expandedParents`, and my first version guarded on `emptied.size`, which silently kept that second prune from ever running — a row stayed expanded on a parent that no longer exists. A mutation found it. There is nothing to pay for dropping the guard: `scrubGridRowMetadata` returns the SAME stage object when it changes nothing, so a close that touches no row still does not re-allocate the stage the user arranged (#681). The missing-tab branch now says something instead of returning quietly. The cause that made it reachable is fixed above, so it should be unreachable — it stays because the next stale target must be visible rather than silent, which is what made this one hard to see at all. The coherence helper in pool.test.ts now asserts the invariant that file's own header already claimed and did not check: no row names a project that does not exist. That is what turns this into a standing guarantee rather than three cases. Mutation-tested, 5 mutations, no survivors: never scrubbing (the bug), scrubbing against the pre-close tab list, against the pre-close session list, and scrubbing before the lane clear rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ure path leave Review findings on #1087, most severe first. 1. The scrub re-allocated EVERY row whenever any row changed, and row identity is load-bearing: the lane-selection race check in dispatch.ts compares row objects across a wake to decide whether the grid moved under the user. So an unrelated close during a cold wake — a window that can stay open 30s — read as "the grid changed", and the agent that had just been woken was never placed, with no toast. Harmless while this helper only ran at the persistence boundary, whose output never entered live state; this PR put it on every close. Now each row returns its ORIGINAL object unless that row changed, pinned by a case in laneSelectionWake.renderer.test.tsx with a control close that scrubs nothing. 2. The legacy `projectTabId` was deleted rather than folded, which takes a live binding off a row whose only crime is being old. Both other readers of that field (`normalizeRowProjects`, `mergeProjectTabs`) fold it first; now this one does too. Dead in live state — rehydrate normalizes before publishing — but this helper just gained a third caller and nothing enforces that order. 3. The toast alone left the user in the dead end it described: the placement overlay only closes on a successful spawn, and the overlay latches `committingRef` before calling, clearing it only in its `open` effect. So "try another lane" was advice the user could not act on without first guessing Escape. The failure path now closes the overlay, and the copy splits on `projectOverride` — an MCP `agents.create` or the New Agent In dialog has no lane in its story. 4. Two WHY comments claimed properties the code did not have: "the one place a project leaves live state" (three sites shorten `tabs`; the other two are consistent for their own reasons) and "runs on EVERY commit" (the early return above skips it, which is why a fully successful Reload All leaves pre-reload ids in `expandedParents`). Both corrected, including what is still not covered. 5. `pool.test.ts` asserted row identity with `toEqual`, which cannot see the property the comment claimed — it is now `toBe`, and it is the assertion that fails on finding 1. The base fixture now binds its rows, so `expectCoherent`'s binding loop is no longer iterating an empty list at eight of its nine call sites: with the bug reintroduced, deleting that loop drops three failures. Mutation results after these changes, whole renderer+unit workspace suites (1224 tests): re-allocate every row 2 failed; delete the legacy fold 1; delete the toast and close 2; keep the toast but not the close 2; one copy for both callers 1; never scrub 7; restore the `emptied.size` guard 1; drop the `expandedParents` prune 3. Deleting the `expectCoherent` loop still passes on correct code, as an invariant assertion should — its value shows up under finding 5's combined run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewer's report worked — finding 1 is a real regression I introduced, and it is the kind that never shows up in a test that was written by the same process that wrote the bug. Point by point. 1. [HIGH] Rows re-allocated wholesale → silent lane-selection drop. Fixed, and pinned. Accepted in full, including the diagnosis: this was harmless while I took the regression test to where the race lives rather than to 2. [HIGH — process] Five survivors, and M4 contradicting the body. Correct on both counts, and the M4 row is the one that bothers me most: I reported "8 failed" for a mutation that is genuinely equivalent — the two helpers write disjoint stage fields — so the most impressive number in that table was measuring nothing. I have corrected the table in the PR body and marked M4 as an equivalent mutant rather than quietly dropping it. Re-run after the fixes, whole renderer+unit workspace suites (1224 tests):
3. [MEDIUM] Comments claiming properties the code lacks. (a) Fixed: (b) Fixed, with your sentence rather than mine: this is the one place a close removes a project; (c) Fixed, and I wrote down the Reload-All consequence you found rather than just softening the claim — a fully successful reload returns early, so live 4. [MEDIUM] The toast fixed half the symptom. Accepted — the latched-overlay detail is what makes this a real finding rather than a nit: advice the user cannot act on is worse than silence. The failure path now calls It is now covered, which it was not: the new case in I left "should be unreachable" out of the comment rather than claiming it, per your 5. [LOW] Right, and the fix is the fixture, not the loop: the base Deleting the loop still passes on correct code — as an invariant assertion should; it only fires when an invariant breaks. The measurement that shows it earns its place is the combined run: with the original bug reintroduced, 6. [LOW/latent] Legacy Fixed — folded into Not changed: M4's ordering (equivalent, now labelled as such), and your
|
…ened Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eviews landed, #1092 root-causes a main flake Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #863.
The bug
scrubGridRowMetadataran only at the persistence boundary (sessionOwnership.ts), so it cleaned the copy autosave writes — not the state the app runs on. Memory and disk disagreed for the rest of the session, and a Grid Dispatch row could stay bound to a project that no longer existed:if (!tab) return nullincreateDetachedDispatchAgent, and fails with no feedback — the placement overlay stays open, because only a successful spawn closes it, and the user has to guess that Escape is the way out.The fix
workspaceWithoutSessionsis the one place a project leaves live state, so it is the one place that has to say so.The scrub runs on every commit, not only when a project left
My first version guarded it on
emptied.size > 0. A mutation caught that this silently kept the helper's second prune from ever running:expandedParentsof sessions that just closed stayed expanded. There is nothing to pay for dropping the guard —scrubGridRowMetadatareturns the same stage object when it changes nothing, so a close that touches no row still does not re-allocate the stage the user arranged (#681). That property is its own test.The silent branch now speaks
The cause that made
if (!tab) return nullreachable is fixed above, so it should be unreachable. It stays because the next stale target must be visible rather than silent — being silent is what made this one hard to see at all.The invariant, not three cases
pool.test.ts's header already claimed "no row ever names a project that does not exist" as its first invariant, andexpectCoherentdid not check it. It does now, so every existing and future case in that file carries the guarantee.Mutation results
clearTiledLaneSessions→lanes,scrubGridRowMetadata→rows), so the order genuinely does not matter. The "8 failed" first published here was wrong, and it was the table's most impressive number.No survivors.
npx tsc -bclean on Node 24.--project unit --project renderer src/renderer/src/workspace/: 1076 passed.🤖 Generated with Claude Code
Review round (reviewer requested changes; fixed in
3af2bc55)A blocking regression was found: the scrub re-allocated every row whenever any row changed, and row object identity is load-bearing — the lane-selection race check in
dispatch.tscompares rows across a wake, so an unrelated close during a cold wake (up to 30s) made a just-woken agent never get placed, silently. Each row now keeps its original object unless that row changed, pinned inlaneSelectionWake.renderer.test.tsxwith a control.Also fixed: the legacy
projectTabIdis folded rather than deleted; the creation failure path closes the placement overlay instead of leaving it up and latched, with caller-appropriate copy;toEqual→toBeon the row-identity assertion; a bound base fixture soexpectCoherent's loop is not iterating an empty list; and two WHY comments that claimed properties the code did not have.Post-fix mutation results, whole renderer+unit workspace suites (1224 tests): re-allocate every row 2 failed; delete the legacy fold 1; delete the toast and close 2; keep the toast but not the close 2; one copy for both callers 1; never scrub 7; restore the
emptied.sizeguard 1; drop theexpandedParentsprune 3.Full reply: the review comment on this PR.