Skip to content

fix(dispatch): wake a hibernated agent before placing it in a Grid Dispatch lane - #691

Merged
Juliusolsson05 merged 4 commits into
mainfrom
fix/dispatch-lane-wake
Aug 30, 2026
Merged

Juliusolsson05 merged 4 commits into
mainfrom
fix/dispatch-lane-wake

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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:

20:07:20.238  delivery.reject  sessionId c0f448e4-…
              code=not-ready  stage=before-write
              registryHit=false  reason="never-owned"

reason: "never-owned" is sessionManager's own discriminator for "main has never held this id this run" — the event exists precisely to separate this from entry-lost-after-owned. And c0f448e4-… is in the persisted workspace under both sessions and detachedSessions: a hibernated detached agent.

Root cause

rehydrate.ts:325 deliberately does not respawn detached sessions, so something must wake them before use. Exactly one path did, and its comment describes this bug precisely:

"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."

But the four gestures that actually put a session into a lane all wrote straight through setTiledLaneSession with no wake: the row index click, a lane strip, ⌥↑/↓, and ⌘N.

They now route through one action that awaits ensureSessionLive for 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. setTiledLaneSession has never woken, including in single-row Tiled Dispatch — git show 18160020^1 shows the same non-waking focusSessionInTab.

#687 made it far more reachable, and both halves contributed:

  • Binding a row to a project promotes scope to global, surfacing other projects' detached agents — the ones most likely to be hibernated after a restart.
  • Removing auto-fill means every lane is now filled by a deliberate pick through one of those four non-waking paths.

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:

  • wakes a hibernated agent before writing the lane
  • does not place an agent it could not wake
  • does not wake a grid-placed session
  • every selection gesture routes through the waking path
  • each row's first lane has no strip; the others do

Note for review

The lifecycle journal also showed never-owned rejects for 1f95ed49-…, 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

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
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round — one blocking finding, fixed

An 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 row

My previous commit captured (row, column) and re-derived the flat lane index after the wake. The comment claimed a row removed above the target would drop the write. It didn't. 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.

The reviewer simulated the real helpers:

reshape during wake expected before fix
earlier row grows [1,2]→[2,2] follow → flat 3 ✅ flat 3
row 0 removed, 2 rows drop ✅ drop
row 0 removed, 3 rows [1,2,2] drop ❌ wrote flat 3 — old row 2's lane
row inserted above drop ❌ wrote into the new empty row
target row itself removed drop ❌ wrote a surviving row's first lane

Concretely: rows [2,1,2], click a hibernated agent in row 1, then run Remove Row while the wake is in flight. A cold wake allows up to 30s, and Remove Row / Close Agent sit on a confirmation dialog inside that window — New Row needs no dialog at all. The agent lands in row 2, evicting what the user was watching in a row they never touched. Precisely the outcome this branch exists to prevent.

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 from

My 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 [2,2] so the inserted row is wide enough that the column bound can't mask the identity check.

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

  • The empty-lane hint said "pick an agent from the strip" in a row's first lane, which no longer has one.
  • Three comments still named the removed setTiledLaneSession workspace method.
  • The wake ran before a coordinate check that already knew the write couldn't land — a discarded provider spawn.
  • Two comments overstated their guarantees: grid-placed does not mean live (a leaf whose respawn failed is selectable and skips the wake — a gap shared verbatim with agentIndexNavigation, pre-existing), and a detached wake is one adopt round-trip, not "almost nothing".
  • gridDispatchLayout clicked row 0's index with focus already in row 0, so it couldn't distinguish focusedLaneInRow from start. Now clicks row 1's index with focus in row 0.

Confirmed fine by review

The unbypassable claim holds structurally — Workspace is ReturnType<typeof useWorkspace>, so a missed call site is a compile error, not a convention. Fire-and-forget is safe. Reachability after the strip removal is intact.

Deferred

⌥↑/↓ coalesces while a wake is in flight — the next-row probe reads the committed lane, so repeats during a cold spawn target the same session with no pending affordance. Real, but it needs a lane-level pending indicator or an optimistic write, which is its own change.

2188 tests passing. Only #684 fails locally, pre-existing and unrelated.

@Juliusolsson05
Juliusolsson05 merged commit 4245d45 into main Aug 30, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the fix/dispatch-lane-wake branch August 30, 2026 20:54
Juliusolsson05 added a commit that referenced this pull request Sep 11, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(dispatch): prompt to a hibernated agent in a Grid Dispatch lane is rejected as not a live agent session

1 participant