Repository navigation
fix(dispatch): wake a hibernated agent before placing it in a Grid Dispatch lane - #691
Conversation
Typing into an agent selected in a Grid Dispatch lane could fail with "Cannot deliver prompt: <id> is not a live agent session". The live lifecycle journal names the cause exactly: reason "never-owned", registryHit false, for a session id that is present in the persisted workspace under both sessions and detachedSessions. Rehydrate deliberately does not respawn detached sessions — they survive a restart as metadata with no provider process — so something has to wake them before use. Exactly one path did, and its comment describes this bug: "Wake under the SAME SessionId before exposing one in a lane/grid slot; otherwise the navigation appears to work but the first keystroke lands on a dead backend." The four gestures that actually put a session INTO a lane — the row index click, a lane strip, the arrows, and cmd-N — all wrote straight through setTiledLaneSession with no wake. They now route through one action that awaits ensureSessionLive for a detached target first, and refuses to place an agent it could not wake, because leaving the lane on its previous occupant is honest where showing a dead pane is not. The wake completes BEFORE the lane is written. Writing first exposes a pane the user can type into during the gap, which is the state being fixed. Grid-placed sessions skip the check: they are owned by a tile tree and were respawned at rehydrate, so the common gesture pays nothing. The gap is pre-existing — setTiledLaneSession never woke, including in single-row Tiled Dispatch. #687 made it far more reachable: binding a row to a project promotes scope to global, which surfaces other projects' detached agents (the ones most likely to be hibernated), and removing auto-fill means every lane is now filled by a deliberate pick through one of those four paths. Also: the first lane of each row no longer carries its own selector strip. That row's index list sits directly beside it and IS its selector — the pairing that giving every row its own index exists for. A strip there was a second selector for the same lane, inches from the first, costing 46px of the row's widest lane. Fixes #690 Refs #681
A wrapper that can be bypassed only fixes the callers that exist today. setTiledLaneSession leaves the public workspace API and becomes private to the dispatch actions, so selectTiledLaneSession is the only way a session can reach a lane and the wake cannot be skipped by a future call site. This is the same "loaded gun pointed at the one thing that can desynchronize" pattern review flagged on the grid PR, applied to the invariant this branch is establishing. It also fixes a test that had silently stopped verifying anything. focusModeKeyboardOwnership asserts that focus mode swallows keys rather than moving a lane's selection, by checking the lane writer was not called — but this branch had just moved the keybind path onto selectTiledLaneSession, so the assertion could no longer fail. Three other suites asserted on the same now-unused mock. All four now name the writer that actually runs. Refs #690
Making selection async introduced a race the synchronous version could not have. `lanes` is flat and row-major, so a lane added or removed in an earlier row shifts every later index. setTiledLaneSession's bounds check catches an index that fell off the end, but an index that is merely now a DIFFERENT row's lane is still in range, and the write lands in the wrong row. The gesture now captures a (row, column) and re-derives the flat index after the wake resolves. The limit is stated rather than glossed: rows have no durable identity, so removing a row ABOVE the target changes the target row's own index and it can no longer be located. That case drops the write. Dropping is the honest outcome — silently retargeting a slot the user did not choose is the surprise this branch exists to remove — and the window needs a reshape to land inside a wake round-trip. A first version of the test asserted the row-removal case FOLLOWED the target. It failed, and it was the test that was wrong: it had encoded the guarantee I wanted rather than the one row identity can support. Both the test and the comment now say what actually happens. Refs #690
Review found the previous commit writes into the WRONG row in exactly the case its own comment and its own test claimed it refuses. A row index is positional: stable against changes to row LENGTHS, never to row MEMBERSHIP. Checking only that the index is still in range drops correctly in a two-row grid, because the stale index falls off the end — and silently writes into a different row from three rows up. Removing row 0 of [1,2,2] put the woken agent into what used to be row 2, evicting the agent the user was watching in a row they never touched. The follow is now gated on the row DESCRIPTOR surviving by reference. Every grid mutation preserves untouched row objects (the lane ops map, the row ops slice and filter), so identity follows exactly the grow and shrink cases and drops every membership change. The window is not narrow, which is why this mattered: a cold wake allows up to 30s, and Remove Row and Close Agent both sit on a confirmation dialog inside it. New Row needs no dialog at all. The test that let this ship green is rewritten. It used a two-row fixture, so its drop came from the index falling off the end rather than from anything about identity — it encoded my claim instead of the behaviour. It now uses three rows, and the insert-above case uses a [2,2] fixture so the inserted row is wide enough that the column bound cannot mask the identity check. Both were verified to FAIL with the guard removed. The reshapes in those tests now call the real grid mutations rather than hand-built next-shapes, because the fix depends on those functions preserving row references — a hand-written reshape tests the test's idea of the reducers, not the reducers. Also from review: the empty-lane hint no longer points at a strip the first lane does not have; three comments naming the removed workspace method are corrected; the wake no longer runs for a coordinate already known to be unresolvable; and two comments that overstated their guarantees now say what is true — grid-placed does not mean live, and a detached wake is one adopt round-trip rather than "almost nothing". Refs #690
Review round — one blocking finding, fixedAn adversarial review caught a real bug in my own race fix, and it's worth recording how. Blocking: the re-derivation wrote to the wrong rowMy previous commit captured The reviewer simulated the real helpers:
Concretely: rows Fixed by gating the follow on the row descriptor surviving by reference. Every grid mutation preserves untouched row objects, so identity follows exactly the grow/shrink cases and drops every membership change. Why it shipped green — the part worth learning fromMy test used a two-row fixture. The drop came from the index falling off the end, not from anything about row identity. It encoded the guarantee I wanted rather than the behaviour I had. Rewritten with three rows, and the insert-above case widened to Both drop-tests were then verified to fail with the guard removed — I didn't just assert they pass. The reshapes now call the real grid mutations rather than hand-built next-shapes, since the fix depends on those functions preserving row references. A hand-written reshape tests the test's idea of the reducers, not the reducers. Non-blocking, also fixed
Confirmed fine by reviewThe unbypassable claim holds structurally — Deferred
2188 tests passing. Only #684 fails locally, pre-existing and unrelated. |
The comment, plan and PR said #687 kept the first lane strip-less. It did the opposite. 524671b (#687) made the index follow focus and gave every lane a strip for that reason. 1eb9a47 (#691, a lane-wake fix) then removed the first lane's strip as a side change, on the grounds that the index was its selector, which had already stopped being true. Both PR reviewers caught it with git show. The record now points at the change that actually regressed, and the plan records the review and its resolutions. Refs #850 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8PfoJxJ1cvJ9akXsWSVZB
Fixes #690 · Refs #681
Two fixes, both reported from real use of the merged Grid Dispatch.
1. Prompt delivery to a hibernated agent (critical)
Typing into an agent shown in a lane could fail with
Cannot deliver prompt: <id> is not a live agent session.This was diagnosed from the live lifecycle journal, not from guesswork. App run
2026-08-30T20-05-09-879Z:reason: "never-owned"issessionManager's own discriminator for "main has never held this id this run" — the event exists precisely to separate this fromentry-lost-after-owned. Andc0f448e4-…is in the persisted workspace under bothsessionsanddetachedSessions: a hibernated detached agent.Root cause
rehydrate.ts:325deliberately does not respawn detached sessions, so something must wake them before use. Exactly one path did, and its comment describes this bug precisely:But the four gestures that actually put a session into a lane all wrote straight through
setTiledLaneSessionwith no wake: the row index click, a lane strip,⌥↑/↓, and⌘N.They now route through one action that awaits
ensureSessionLivefor a detached target, and refuses to place an agent it could not wake — leaving the lane on its previous occupant is honest; showing a pane whose backend refused to return reproduces the same failure one keystroke later.The wake completes before the lane is written. Writing first exposes a pane the user can type into during the gap, which is the state being fixed. Grid-placed sessions skip the check entirely — owned by a tile tree, respawned at rehydrate — so the common gesture pays nothing.
Is this new?
The gap is pre-existing.
setTiledLaneSessionhas never woken, including in single-row Tiled Dispatch —git show 18160020^1shows the same non-wakingfocusSessionInTab.#687 made it far more reachable, and both halves contributed:
global, surfacing other projects' detached agents — the ones most likely to be hibernated after a restart.I'm not claiming this as "not ours". It is a latent bug our feature walked directly into, and the reachability change is the reason it surfaced now.
2. The first lane of each row belongs to its index list
Reported directly: "why does the leftmost agent have the mini list? the leftmost agent of course belongs with the large list on the side, that is kind of the whole point."
Correct, and it's a design call I got wrong in #687. I removed lane 0's special case on the grounds that "the index belongs to the row, not to its first lane" — but the row's index sits directly beside its first lane and is that lane's selector. A strip there was a second selector for the same lane, inches from the first, costing 46px of the widest lane in the row.
Every other lane still needs its own strip, because the index is already spoken for.
Verification
typecheck,check:keybindings,test:contract, and the full deterministic suite (2184 passing) all green. Only #684 fails locally — unrelated, pre-existing.New tests assert the order, not merely that a wake happened, because writing the lane first is the actual defect:
Note for review
The lifecycle journal also showed
never-ownedrejects for1f95ed49-…, which is my own orchestration parent session from the review agents I ran earlier — prompts sent to children after I closed the run. Unrelated to this bug; flagging it so nobody chases it as evidence.🤖 Generated with Claude Code
https://claude.ai/code/session_01VLvdUFXQ7rjUVchjwEPfNf