fix(runtime): park a mirrored pane's resume until its PTY handle lands (#19882)

* test(repro): #19735 resumes a published mirrored pane before its handle lands

* fix(runtime): park a mirrored pane's resume until its PTY handle lands

Mirror hydration means the host's tab rows arrived, not that a given pane's
liveness is decidable: the PTY handle lands one relay round trip later. On
that frame the pane read as not-live and the sweep resumed a session the
host was still running, producing a duplicate resume tab.

An empty handle map for a published row is unverifiable, never exited. Park
the pane on a per-pane wait with three bounded exits, each replaying the
sweep: its own handle lands, the row is retracted, or a deadline expires.
The deadline decides resume rather than an indefinite hold, and is scoped
to the connection generation so a reconnect re-arms it.

Closes #19735

* fix(runtime): bound the handle-gap expiry map to the current connection

* fix(runtime): void a handle-gap verdict the reconnect made stale

The per-pane park bounds itself with one deadline per connection, but the
waiter never recorded WHICH connection it was armed on. A wait armed on
generation 0 that fires after a reconnect stamps its expiry against the
current generation, so hasHostMirrorHandleWaitExpired agrees, the mirror
lookup returns null, and the pane is resumed after 1ms on a connection
that has had no chance to publish the handle. That is #19735's fork with
an extra step, reached through the guard that exists to prevent it.

The module's own doc comment claims the opposite -- "a reconnect bumps
the connection generation and arms a fresh wait" -- and that is true only
for a wait which had ALREADY expired, which is precisely the case the
existing test covered. The test and the comment agreed with each other
and both were wrong about the live case.

The waiter now carries the generation it was armed on and records no
verdict when the generation has moved; the replay re-parks through the
existing machinery and the new connection gets its own full budget. Still
bounded per connection generation, which is what was documented all along.

Also pins the three sibling attacks on the same window: two panes in one
environment where only one handle lands, a handle published by a foreign
environment, and an environment tearing its rows down mid-park (which
leaves no waiter and no scheduled timer).

The test file now leads with how to assert on this module at all, because
the obvious shape cannot fail. "Did the waiter release" is not an
observable here -- a waiter released for the wrong reason is re-parked by
the replayed sweep, so the store reads identically one tick later, and a
mutation releasing every waiter on any tab's handle survived twelve
assertions written that way. What a spurious release costs is the
deadline, so the assertions advance the clock and require the pane to
decide on the ORIGINAL schedule.

* fix(terminal): a live pane owns its transcript in any workspace

The resume dedup was scoped to the record's own workspace on both terms
-- the entry's tab had to be in worktreeTabIds AND entry.worktreeId had
to match -- and additionally required entry.state !== 'done'. A record
whose peer pane has finished a turn and still holds a live PTY therefore
matched nothing, and the sweep launched a second agent onto a transcript
the peer is still writing. Cross-workspace, it matched nothing even while
the peer was mid-turn.

The two ids really do drift. canonicalizeTerminalSessionWorktreeId
re-keys tabsByWorktree, tabGroups, tabGroupLayouts, activeTabIdByWorktree
and activeGroupIdByWorktree onto the canonical worktree id, and does NOT
re-key sleepingAgentSessionsByPaneKey, whose records carry worktreeId
inside them. So adopting an orphaned terminal is a direct producer of a
record naming one workspace while its pane and status row name another.

Split into two arms rather than widening the existing condition. The new
arm carries no workspace scope but demands hard evidence: a provider
session id names one transcript, so a pane whose exact PTY is live right
now already owns it wherever that pane sits, and no workspace boundary
makes a live PTY less live. The scoped arm keeps its scope and its
state !== 'done' term, because a status row with no live PTY is a claim
about the past and must not reach across workspaces.

Relationship to #19736: that PR fixes the SAME-workspace half of this in
the same function, by relaxing only the status term. This arm covers that
cell too -- measured both ways on this branch, which does not carry
#19736: its thirty `checks exact live ownership before resuming` cases
all pass with this change alone, and ten of them fail without it. So this
supersedes #19736 rather than sitting beside it, and #19736's one-line
`export` of stablePaneHasLivePty is carried here because this arm needs
it. If #19736 lands first this becomes a pure widening and its tests
should be kept. Both cells are pinned here either way.

* fix(runtime): isolate one pane's replay from the handle-gap drain

One store write releases every due pane, and the drain runs synchronously inside
a zustand subscriber. `waiter.run()` was unguarded, so a single pane's replay
reached two things it has no business touching:

  - the throw escapes out of `useAppStore.setState`, meaning the mirror apply
    that published the PTY handle throws at its own call site;
  - every pane queued behind the thrower is stranded — waiter still parked,
    deadline still armed — and then decides on a connection whose evidence
    landed long ago.

The deadline path fans out the same way, so a throwing replay also escaped the
timer callback.

Reachable: `resumeSleepingAgentSessionsForWorktree` reaches `state.createTab`
with no guard of its own. The panes in a drain are strangers to each other and
to the frame that released them; none of them should be able to see another's
failure.

The new tests live in their own file because
host-mirror-handle-gap-resume.test.ts drives the waiter through the real resume
sweep and so cannot choose what a replay DOES. Note for anyone extending that
file: per its header, "did the waiter release" is not an observable here — a
spurious release is re-parked immediately and reads identically one tick later.
These tests assert on timer count and on the deadline instead.

Also records two findings next to the code, so they are not rediscovered:
`expiredGenerationByPane` is never pruned for a removed environment (bounded and
inert, since removal advances the generation, but it does not drain — and a
DIFFERENT leak in that same map is being fixed concurrently, so reconcile rather
than patch around it); and sustained reconnect churn holding a pane parked
indefinitely is CORRECT, not the latch-that-never-releases defect, because under
churn liveness genuinely is unverifiable and ssh-execution-boundary.md forbids
resolving that to `exited`. It has the shape of the defect and will eventually
be "fixed" by someone who does not know that.

Mutation: dropping the guard kills exactly the three new assertions and leaves
all twelve existing waiter tests passing.

* fix(runtime): drain a removed environment's handle-gap verdicts on teardown

`expiredGenerationByPane` is pruned only by rules that run when a verdict is
RECORDED — the stale-generation sweep here, and the tab-death sweep added
separately (8f16641130, env-scoped in c0e44238ea). An environment that is
REMOVED records nothing ever again, so neither rule can reach its rows and they
survive for the life of the session. Two orphan classes on one map; neither
prune subsumes the other, because both are driven by a recording.

Severity is a leak, not a correctness bug, and the commit pins WHY so nobody
re-derives it: removing an environment advances its connection generation, so a
stranded verdict can never match again even if the id returns. That test exists
to stop the generation advance being "optimised" away later, since it is the
only thing making the stranded row inert.

Hung off `clearWebSessionTabsTrackingForEnvironment` because that is the only
caller that fires for an environment that is going away.

Clears VERDICTS ONLY. Parked waiters deliberately survive, matching
`clearHostSessionMirrorHydration`: a re-pair or effect restart replaces the
connection's evidence, it does not cancel the recovery this client still owes
the pane. A waiter left behind is bounded by its own deadline and replays its
sweep exactly as it would have. Clearing them here would silently drop a parked
resume that nothing else replays.

A measurement worth recording, because it argued me out of a change I was about
to make: on the unfixed map the per-expiry rescan is super-linear — 500/1000/
2000/4000 sequential expiries cost 7.2/15.3/51.8/173.1 ms, doubling ratios
converging on ~3.35 against 4.0 for quadratic. That looked like a case for
reshaping the map to `Map<env, {generation, Set<tabId>}>`. It is not: the
quadratic is a property of the LEAK, not of the scan. Once the tab-death prune
holds the map at roughly one entry per environment the scan is over ~1 entry,
and a counting probe on the fixed map (summing `map.size` across N expiries,
which IS the iteration count and needs no clock) gives exactly N-1 — linear, and
2000x fewer iterations than quadratic at N=4000. The flat prefix loop used here
is the established pattern in this subsystem and needs no restructure.

Two methodology traps this cost, recorded for the next person measuring in this
repo: `vi.useFakeTimers()` fakes `process.hrtime` and `performance.now` as well,
so a timing harness reports the advanced deadline rather than work done — fake
only the timer surface under test. And expiring N panes in one burst measures
the fake-timer harness clearing N timers, not product code; 1000 panes "cost"
~1s that way and almost none of it was ours.

Mutations: a clear that drops nothing kills exactly the two assertions that
claim it drains, and correctly leaves the waiter-survival and generation-advance
tests passing. An UNSCOPED clear kills the same two, via their sibling-
environment half.

* fix(runtime): reconcile three branches' handle-gap verdict rules into one loop

Three agents changed `recordExpiredWait` on three branches and each verified only
their own. This is the union, resolved into the agreed shape and proved on one
tree. The rules are NOT alternatives — they have different safety properties, and
flattening them to one scope is wrong in both directions. Both wrong shapes were
independently written before this was reconciled, so the comments say why.

GENERATION rule, per key across EVERY environment (adv2-skew's class).
`hasHostMirrorHandleWaitExpired` compares a row against its own environment's
CURRENT generation, so a row whose generation has moved can never return true for
anybody; retiring it cannot cost a reader a verdict, whoever owns it. Scoped to
the recording environment, an environment that reconnects and then goes quiet
strands its rows forever.

TAB-DEATH rule, recording environment ONLY (my class). Row absence is transient
where a generation is not: a sibling mid-republish has no rows for a frame and
would lose a verdict its pane still needs — reproduced before it was narrowed.

Teardown drain (adv2-races' class) is unchanged and orthogonal: it is the only
trigger that fires for a REMOVED environment, whose rows no rule above reaches
because such an environment records no further verdict. Right predicate, wrong
trigger.

The union suite proves all four orphan classes simultaneously, plus the two
properties none of the three rules may break: the verdict stays sticky enough to
break the park/expire/replay loop, and no rule evicts a verdict a live pane still
needs. It uses three environments throughout, because with two at one generation
the candidate rules are indistinguishable and the naive fix survives.

THE FOURTH CLASS IS UNOWNED AND ASSERTED AS A HAZARD. A retracted tab id that is
republished inherits the old pane's verdict and skips its own wait. Unlike every
other gap on this map it is NOT conservative: the others drop a verdict and
re-park, holding longer, while this one retains a verdict and resumes on a handle
that has not landed — the #19735 direction. No rule reaches it: the tab-death
predicate stops matching once the id is republished, the teardown drain fires on
environment teardown rather than tab retraction, and no waiter exists to observe
the retraction because a pane holding a verdict never parks. Closing it needs a
fourth trigger, on row retraction. The suite pins the current behaviour so it
cannot be quietly forgotten.

Union finding, recorded rather than merged: adv2-skew's
`docs(relay): the live-broker wait budget does not bound the call` (6b029820cc)
is SKIPPED here. It documents the unbounded wait, and adv2-concurrency-fixes
(1673716c6d) fixed exactly that by extracting the loop into
relay-live-broker-wait.ts. The doc and its test pin behaviour the union no longer
has. This is the kind of interaction neither branch could see alone.

* fix(test): repair the teardown suite the union broke

Cherry-picked from 46ad377ceb with the relay half dropped: that commit
also repaired relay-concurrency-policy-flip-mid-mint.test.ts, which does
not exist on this PR and belongs with the relay cluster's own branch.

The handle-gap half is what this PR needs. Neither break was visible on
its own branch -- both only appear once the verdict rules compose.

* fix(runtime): a handle-gap verdict answers for its pane, not for the tab id

Folds adv2-skew's e8cac056d7 into the reconciled union. Closes the fourth orphan
class, the only one that was not conservative: a retracted tab id republished as a
different pane inherited the old pane's verdict and skipped its own wait — the
#19735 direction rather than a longer hold.

It needs no fourth trigger, which is why it composes with the three drains rather
than competing with them. Every trigger those rules own fires downstream of the
moment this hazard needs. The verdict instead carries the environment-minted PTY
binding its pane held AT PARK TIME, and only answers for a pane that still holds
it: a republished pane binds a newly minted PTY and serves its own wait, while a
genuine reattach to the same PTY inherits, which is correct — the verdict follows
the PTY, not the id. A transient rowless frame touches neither, so the read-time
check is safe where a retraction-triggered prune would not have been.

TWO MEASUREMENTS, both requested rather than assumed.

1. The record-then-release ordering is load-bearing and IS pinned. `recordExpiredWait`
reads the waiter's park-time binding, so it must run before `releaseWaiter` deletes
the entry. Swapping the two statements fails three cases, so the capture is not
correct merely by accident of statement order.

2. The `''` fallback is a MATCH VALUE, not a null: two panes that both hold no
environment-minted PTY compare equal and inherit, which is the same hazard in a
narrower window. Measured unreachable through the production park path rather than
assumed — the only route in is `kind: 'handle'`, which `findUnhydratedHostMirrorForPane`
reports only when `tabHoldsEnvironmentPtyBinding` finds a binding, reading the SAME
map through the SAME predicate as `paneBindingFor`. It now refuses to answer anyway.
That coupling is two functions in two files with nothing enforcing it, refusing costs
only a re-park, and the direction is conservative.

THE REFUSAL IS WHAT FOUND THE REAL BUG. With `''` matching, any fixture that omits
`terminalLayoutsByTabId` records `''`, compares `'' === ''`, and passes while the
pane-identity check is entirely inert. Making it refuse turned that silence into
four failures across host-mirror-handle-gap-drain and -teardown, whose fixtures seed
no layout binding at all. Both now bind per environment — one shared environment id
filters every other environment's pane back to `''` and restores the no-op.

Mutation-tested on the merged tree: ignoring the binding fails case D and the
mid-wait case; re-reading at expiry fails the mid-wait case and nothing else;
letting `''` match fails the empty-binding case; widening the tab-death rule across
environments still fails the live-verdict case, so pane identity does not weaken the
scoping the sweep was reconciled around.

Also fixes a real-clock race this branch introduced: the revoke-window test read
`Date.now()` separately from `enqueue`'s own stamp, and under load the drift ate
into the window. It now anchors the injected clock to the item's `createdAt`.

* docs(runtime): the two guards on the park-time binding are not redundant

Recording a reconciliation result that existed only in a review thread, and
correcting it in the process — measuring the claim changed it.

The claim under review was that the `?? ''` fallback in `recordExpiredWait` is
unreachable by two independent guards, either sufficient alone: the caller's
generation gate (a missing waiter fails `undefined === number`) and the
record-before-release ordering. That is not what the code does.

Measured, by removing each in turn:

  - ordering removed, generation gate kept: the gate does NOT carry it. With the
    waiter already deleted, the gate is false on every expiry, so nothing is ever
    recorded — five failures, and the door is shut by breaking the mechanism rather
    than by refusing ''.
  - generation gate removed, ordering kept: 736 files green, one failure, and it is
    `does not let a wait armed on the previous connection decide the new one` in
    host-mirror-handle-gap-resume.test.ts — a different property entirely.

So the ordering alone makes `''` unreachable, and the generation gate is not a second
guard on it at all: it pins reconnect-void. Both are load-bearing, for different
reasons, which is a stronger argument against removing either than redundancy would
have been — redundancy invites deleting one.

Worth writing in the file because the two sit three lines apart and read as belt and
braces on the same thing. The `''` comment next to them already exists because an
unexplained guard on an unreachable value gets deleted as dead code in a year; a guard
that looks redundant is deleted sooner.

No behaviour change. One comment, corrected against measurement rather than against the
thread it came from.

* fix(runtime): a published handle retires the verdict it answered

The fourth eviction trigger on `expiredGenerationByPane`, and the reason it is
not redundant with the three already there or with the two other agents' guards
on this same map.

A verdict records that a pane's 15s handle-gap wait ran out. Nothing retires it
when that pane subsequently publishes its handle, so the NEXT gap on that pane
gets no wait at all — #19735 with the bounded wait removed rather than merely
shortened. Measured on the reconciled union tree (1b621b6b13) plus the outage
guard: the verdict still answered `true` after the handle landed, and the second
gap resumed with zero panes parked.

Why none of the existing rules reach it, each checked rather than assumed:
  - superseded generation: #19647 in this same stack stops recording
    `status: null` for an unreachable host, so the generation no longer moves
    across an outage on one runtime.
  - dead tab row: the row stays published throughout. It is the HANDLE that
    comes and goes — that is the definition of the gap.
  - removed environment: the environment is still here.
  - read-time pane identity (adv2-skew, cdafc90d8f): the pane keeps the same
    layout binding across the gap BY DESIGN, and the union suite pins that a
    genuine reattach to the same PTY must inherit. That check discriminates a
    different pane behind one tab id; this one discriminates a later gap on the
    same pane.
  - contact lost (adv3-journeys, 2da662424b): no outage is involved; this is a
    healthy connection where the host was simply slow once.

Composition proven by mutation on the union tree, four disjoint kills: dropping
this drain kills 2 tests and only mine; dropping the re-park worktree kills 1 and
only mine; dropping the contact guard kills 1 and only theirs; forcing the
contact guard always-true kills 16 across every suite. No mutation kills another
agent's test, so these are three guards on three holes, not three on one.

Also carries `worktreeId` across a re-park: adopting an orphaned terminal re-keys
`tabsByWorktree` without re-keying the record, so a live wait kept releasing on
retraction evidence about the workspace it was no longer about. The park-time
`paneBinding` deliberately does not move with it — that is the pane's identity,
this is only where its rows are filed.

* docs(runtime): the reused-tab-id class is closed at read time, not still open

The `ExpiredHandleGapVerdict` docstring told the next reader that a retracted
tab id republished under the same id still inherits its predecessor's verdict,
and that closing it "needs a fourth trigger, on row retraction". The test it
names as its own pin says the opposite: class D in
host-mirror-handle-gap-verdict-union.test.ts asserts the verdict does not
answer, and explains it is closed at READ time rather than by any prune.

Provenance, since two sources disagreeing is what made this expensive: the
paragraph was last written in 46ad377ceb and the read-time pane-identity check
landed one commit later in cdafc90d8f (adv2-skew). The prose predates its own
fix by a single commit and was never updated. Confirmed by mutation rather than
by reading: dropping `verdict.paneBinding === paneBindingFor(...)` fails exactly
"handles all four orphan classes simultaneously", which is the class-D
assertion, so the read-time check is what closes it.

Rewritten to say what the code does, keeping the part that was always true —
why no trigger could have reached that class — and keeping the distinction the
new PUBLISHED HANDLE drain needs: read-time identity separates two panes behind
one tab id, the drain separates two gaps on one pane. The drain does not close
class D and must not be read as closing it.

Also records why this block specifically keeps going stale: several agents
change this map in parallel, the invariants move faster than the prose, and when
the two disagree the test file is the one that ran.

* test(runtime): pin replay containment on the deadline path too

Cherry-picked from aa98edf35a (nwparker/adv3-failure-fixes) with its
implementation hunk dropped: a second agent found the same throwing-replay
hole independently, and `15f34014153` already closed it on this branch with
an equivalent guard. Applying both would have been a double-apply, and the
two spellings of the log line would have shipped side by side.

The tests are worth keeping regardless. The first duplicates coverage
15f3401415 already has; the second does not -- it drives the throw from the
DEADLINE path rather than the store-write path, which is a separate call into
releaseWaiter and was unpinned.

Spies retargeted from console.error to console.warn, the channel the guard
that actually shipped writes to, so the suite silences what the code emits.

* fix(runtime): isolate one worktree's replay from the mirror-hydration drain

The same fan-out hazard as the handle-gap drain, one module up. Settling an
environment drains every worktree parked on it in a single loop, called from the
frame apply, with `waiter.run()` unguarded. One replay that throws strands every
waiter queued behind it and surfaces in the caller applying the frame.

Found by looking for the sibling of a defect rather than by a separate
interleaving: both modules park a `run` callback and drain N of them from one
event, so both have the same blast radius. Kept as its own commit because the
two modules route independently.

Mutation: dropping the guard kills exactly the one new assertion.

* test(runtime): pin sleeping-agent resume on a failed SSH target

The terminal-state floor in workspace-terminal-host-authority.ts has three
consumers: initial-terminal seeding, the startup terminal watcher, and
sleeping-agent resume. Seeding is covered end to end by
worktree-agent-activation-seam.test.ts. Resume was covered only at the
predicate, so nothing failed if the floor stopped reaching it — and the
floor's own comment says the cost of losing it is a failed target left
terminal-less with unresumable agents for the rest of the app session.

Pins the resume half directly: an SSH git worktree on a target whose sync
terminated in offline/error with an empty hydrated set resumes its sleeping
agent. Two controls keep the floor from widening into "resume whenever we
are unsure" — an in-flight 'pulling' sync and no sync status at all both stay
unverifiable and resume nothing.

Verified by mutation: emptying TERMINATED_WITHOUT_ANSWER_PHASES fails exactly
the two floor assertions and leaves both controls passing.

Routes independently of the two fixes on this branch: the floor predates this
stack (#16750), and this only closes a coverage gap in it.

* test(runtime): pin the store subscription the reconciled loop can leak

The retention suite that `reconcile three branches' handle-gap verdict rules
into one loop` replaced carried an assertion the split suites did not: the
store subscription is held for exactly as long as something needs it.

Measured before writing it, because half of it was already covered:

  RETAIN direction -- drop the verdict term from `stopStoreSubscriptionIfIdle`
  so a verdict with no waiter behind it loses the subscription its drain needs:
  already caught, 2 failures in host-mirror-handle-gap-landed-handle.test.ts.

  RELEASE direction -- never release the subscription at all: caught by
  NOTHING. That mutation passes all 272 tests across the 33 other handle-gap
  and session-tabs suites. A leaked subscription rescans every parked pane on
  every store write for the life of the session and nothing notices.

So this is for the release direction. The retain cases ride along because both
halves of one invariant belong in one file, not because they were missing.
That term is also precisely what the reconcile moved -- it now counts verdicts
as well as waiters -- so it is the part of this map most likely to drift again.

Asserted with a spy on useAppStore.subscribe rather than a new test-only
export: whether the module is subscribed is already observable at the store
boundary, and the production surface should not grow just to say so.

* fix(lint): carry SAFETY rationales for the handle-gap fixtures

main tightened typescript/consistent-type-assertions to assertionStyle:
never after this branch was written. The gate only ran here once the
rebase put the casting config at the merge base, so these sites are new
to it, not new to the branch. The store seeds are genuinely partial --
dropping the casts does not typecheck -- so each carries its rationale.

* fix(runtime): release a handle-gap pane once per store write

`releaseDueWaiters` snapshotted the due KEYS and then re-looked-up each one. A
replay earlier in the loop writes to the store — the sweep reaches `createTab`
and `clearSleepingAgentSession` — and zustand notifies re-entrantly with no
queue, so the nested pass can release and re-park a pane still queued in the
outer loop. The outer `releaseWaiter(key)` then found the re-park, cleared its
brand-new deadline and replayed it a second time off one store write, handing
that pane another full budget.

That is the extension `parkUntilHostMirrorHandleLands` already refuses to grant
a re-park, arriving through a different door. The direction is conservative
(hold longer, never resume early), which is why no outcome assertion could see
it; only the replay count separates the two implementations. Snapshot the waiter
alongside its key and release only while the map still holds that same waiter.

Also folds host-mirror-handle-gap-replay-containment.test.ts into the drain
suite, since the guard it pins is the one this commit extends. It was the same
fix imported twice: its deadline case is a strict subset of the drain suite's,
its store-write case differs only by also asserting that later store listeners
still run, and one mutation — rethrowing from the replay catch — killed all five
cases across both files. Its fixture also seeded no layout bindings and used tab
ids `isWebTerminalSurfaceTabId` rejects, so those panes could not have reached
the park path it claimed to exercise. The unique assertion moves across; the
file goes.

Killed by `releases a pane once per store write even when an earlier replay
re-enters the drain`: 2 replay calls instead of 1 without the identity guard.

* test(runtime): retire the handle-gap assertions that could not fail

`returns to baseline under churn across all three drains` asserted nothing. It
ran 300 expiries and then cleared every environment's verdicts by name before
counting, so the map was empty by construction — deleting the whole prune loop
in `recordExpiredWait` left the test green. It now asserts the bound BEFORE the
teardown clear: 300 expiries must leave exactly one live verdict per
environment. It also binds each round's pane to the environment recording it;
the old fixture filed every binding under env-a, so two rounds in three stored
the empty match value the read-time check refuses, and that much of the churn
was synthetic. Renamed: there are four drains, not three.

Two comments described outcomes their assertions do not produce. `c1` reads
false on the read-time generation gate alone, whether or not a drain ever swept
it — the count below is the only assertion that distinguishes retired from
stranded. And the discriminator in `never evicts a live pane verdict` is env-b,
whose row goes absent while env-a records; env-c is a control that holds under
every candidate rule.

Two more fixtures modelled states the mirror apply cannot produce, both leaning
on `ptyIdsByTabId[tab]` holding a PTY id no leaf of that tab is bound to. It
builds one from the other (web-session-tabs-sync/terminal-build.ts), so they can
never disagree. The producible shape is a SPLIT tab whose sibling surface went
`ready` first, which is what both cases now seed — and which makes the residual
they were quietly standing in for visible instead: the decidability gate above
this wait is tab-granular while everything below it is leaf-aware, so a sibling
handle ends the wait for a surface still `pending-handle`. Recorded at the gate
in host-mirrored-pane-liveness.ts, pinned by name, and left open here: it needs
the per-surface status the host already publishes and the client drops on apply.

Also states what the unscoped live-PTY arm trades — a finished agent whose shell
is still up releases its record and will not auto-resume — because it reads as a
regression and is not one. And replaces the subscription-lifetime header's
unreproducible "272 tests across 33 suites" with the measured 326 across 37.

* fix(runtime): re-judge a handle-gap waiter the drain's own replay moved

The identity guard added one commit ago catches only half of how the drain's
snapshot goes stale. It proves the map entry was not REPLACED; it cannot prove
the verdict still holds, because `parkUntilHostMirrorHandleLands` re-parks a
still-parked pane by MUTATING the waiter in place. `worktreeId` moves with `run`
— that is what adopting an orphaned terminal does — and object identity survives
it. So a waiter the snapshot judged retracted, because its tab was absent from
the worktree it was filed under, can be re-filed by an earlier replay in the
same loop and then released on evidence about a workspace it is no longer about.
That is the defect the `existing.worktreeId` assignment exists to prevent,
reached through the drain instead.

Neither guard covers the other: re-judging alone still replays a re-park twice
(it was just made due, so it re-judges due), and identity alone misses the
mutation. Both, in that order. A waiter that is no longer due simply stays
parked — bounded by its own deadline and re-judged on the next store write, so
declining costs at most one frame of latency.

Killed by `does not release on retraction evidence a mid-drain re-park has
already made stale`; the identity half is still killed by `replays a pane once
per store write even when an earlier replay re-enters the drain`. 29 mutations
across these modules, no survivors.

Corrects three claims made in the two preceding commits, each wrong in a way a
future reader would have acted on:

- the duplicate release does NOT extend the pane's budget. `releaseWaiter`
  deletes the waiter before calling `run`, so the re-park takes the `!existing`
  branch and arms a full deadline either way; a second release clears and re-arms
  at the same instant. What it costs is running an entire worktree resume sweep
  twice off one frame. The test is renamed to say so.
- `retainPendingTerminalBindings` carries a `pending-handle` surface's prior
  binding forward, so the split-tab residual cannot arise from a bound leaf going
  pending — it needs a leaf that was NEVER bound, which means a cold start or
  re-pair with no layout to retain from. The fixture staged the impossible
  history; it now seeds the producible first frame, where the point sharpens: no
  wait is armed at all, which the test now asserts directly.
- `clearSleepingAgentSession` cannot re-enter the drain; the subscription's slice
  guard drops that write. Only `createTab` can.

Also: the churn assertion pins that the prune loop runs at all, not which rule
prunes — with one live tab per round either rule alone still reads 3. Says so,
and points at the case that does isolate the generation rule.

* test(runtime): stage the handle-gap adoption the way a sweep can reach it

The case added one commit ago pinned the right guard through a call sequence
production cannot make. A replay is `resumeSleepingAgentSessionsForWorktree`
closed over ONE worktree, so it re-parks only under that worktree — but the
fixture had the first pane's replay re-park the second under a DIFFERENT one.
That is the same fault the previous commit corrected in the resume fixture, made
one file over.

The reachable route: the second tab has already been re-keyed onto the canonical
worktree id while its sleeping record still names the old one, so the first
pane's sweep legitimately owns it and re-parks it there, mutating the live
waiter in place. The waiter's snapshot verdict — "retracted", because the tab is
absent from the id it was filed under — is stale by the time the loop reaches
it. Restaged that way, with both waiters and the re-park inside one worktree.

Also drops the second global store read. Guard TWO now re-judges against the
same `state` the drain was notified with, because the two provably cannot differ
here: a re-park only happens when `findUnhydratedHostMirrorForPane` finds the
row already filed under the sweeping worktree, which is a row this frame carries.
`useAppStore.getState()` was an unpinnable degree of freedom — swapping it for
`state` left every test green — and it contradicted the drain's own claim to
judge one snapshot.

And corrects two more claims: guard ordering is a cost preference, not a
correctness requirement (either order works; identity first is just cheaper),
and "no wait is armed here" in the split-tab fixture is not caused by the cold
start — it is the tab-granular gate reading the sibling's handle, which is the
residual itself.

29 mutations across these modules, no survivors. Each guard is killed by exactly
one case and they do not overlap: `replays a pane once per store write even when
an earlier replay re-enters the drain` for identity, `does not release on
retraction evidence a mid-drain adoption has already made stale` for re-judging.

* fix(runtime): judge a re-parked handle-gap waiter on the live store

Reverts the `state` read the previous commit put in guard TWO, and says why the
difference is deliberately unpinnable rather than leaving the next reader to
"simplify" it back.

The previous commit swapped `useAppStore.getState()` for the subscriber's
`state` because a reviewer noted the swap left every test green. That was
optimising for mutation-killability over the property the module exists to
protect. The two reads do agree on every sequence the sweep can produce — a
replay's only store write is `createTab`, which appends a freshly minted tab id,
so it can neither make an absent tab id present nor touch `ptyIdsByTabId` — which
is exactly why no test separates them. But they are not interchangeable: `state`
is the staler of the two, and its failure direction is to RELEASE a pane whose
row has come back. That is resolving unverifiable to exited, which is #19735.

Holding on evidence that might be stale costs one frame; acting on it forks a
transcript. Take the fresher read, and record in the comment that no test can
fail on this and why that is not a reason to change it.

Both guards remain killed by exactly one case each and they do not overlap:
`replays a pane once per store write even when an earlier replay re-enters the
drain` for identity, `does not release on retraction evidence a mid-drain
adoption has already made stale` for re-judging.

Verified while confirming the previous commit's test premises against production,
both of which hold: a sweep that parks every record writes nothing to the store
(resume-sleeping-agent-session.ts takes `continue` on the park branch), and
`workspace-session-worktree-id.ts` moves `tabsByWorktree` onto the canonical id
while leaving `sleepingAgentSessionsByPaneKey` naming the old one — the stale
`worktreeId` the adoption case depends on.
This commit is contained in:
Neil
2026-09-16 22:23:51 -07:00
committed by GitHub
parent ea01cd0ccd
commit e45cf438bc
15 changed files with 2141 additions and 34 deletions
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// What this file pins, and why it is separate from host-mirror-handle-gap-resume.test.ts: that file
// drives the waiter through the real resume sweep, so it cannot choose what a replay DOES. These
// tests park with a `run` of their own to exercise the drain itself — the loop that releases every
// due pane from one store write, running synchronously inside a zustand subscriber. The panes in
// that loop are strangers to each other and the store write that triggered it is a stranger to all
// of them, so one pane's replay must not be able to reach either.
//
// Both release paths are here on purpose: the store-write drain and the deadline both funnel into
// `releaseWaiter`, and a guard added to one is easy to forget on the other. One mutation —
// rethrowing from that catch — kills the first and last cases together, which is the point: they
// are the two entry points, not two behaviours. The two middle cases are about what one replay can
// do to the pane queued behind it while the drain is mid-loop, and neither involves a throw.
const ENVIRONMENT_ID = 'env-handle-gap-drain'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const FIRST_TAB_ID = 'web-terminal-host-tab-1'
const SECOND_TAB_ID = 'web-terminal-host-tab-2'
const initialAppStoreState = useAppStore.getState()
function seedRows(): void {
// Layout bindings are seeded because a verdict names the PANE by the environment-minted PTY it
// held at park time. A pane with no binding never reaches the park path in production, and its
// verdict deliberately refuses to answer, so a fixture without one models nothing real.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: FIRST_TAB_ID, title: 'one' },
{ id: SECOND_TAB_ID, title: 'two' }
]
},
terminalLayoutsByTabId: {
[FIRST_TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': `remote:${ENVIRONMENT_ID}@@term_1` }
},
[SECOND_TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-2' },
activeLeafId: 'leaf-2',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-2': `remote:${ENVIRONMENT_ID}@@term_2` }
}
}
} as never)
}
/** The host publishes both panes' PTY handles on one frame: both waiters come due together. */
function publishBothHandles(): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: {
[FIRST_TAB_ID]: [`remote:${ENVIRONMENT_ID}@@term_1`],
[SECOND_TAB_ID]: [`remote:${ENVIRONMENT_ID}@@term_2`]
}
} as never)
}
describe('host-mirror handle-gap drain', () => {
beforeEach(() => {
vi.useFakeTimers()
// The replays below throw on purpose; the module logs and swallows, which is the behaviour
// under test, so the log itself is noise.
vi.spyOn(console, 'warn').mockImplementation(() => {})
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
seedRows()
})
afterEach(() => {
vi.restoreAllMocks()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
// The drain runs inside `useAppStore.subscribe`, and zustand notifies listeners in a plain loop
// with no queue, so an unguarded throw from one pane's replay reaches three strangers at once:
// the `setState` that published the handle (the mirror apply, which has nothing to do with this
// pane), every sibling pane the same frame made due, and every listener registered after this
// module's. `resumeSleepingAgentSessionsForWorktree` reaches `state.createTab` with no guard of
// its own, so the throw is reachable.
it('does not let one panes replay throw reach the store write, its siblings, or later listeners', () => {
const siblingReplay = vi.fn()
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
throw new Error('replay blew up')
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, siblingReplay)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2)
// Registered after this module's subscription, so it is notified after the drain.
const laterListener = vi.fn()
const unsubscribe = useAppStore.subscribe(laterListener)
expect(() => publishBothHandles()).not.toThrow()
unsubscribe()
expect(siblingReplay).toHaveBeenCalledTimes(1)
expect(laterListener).toHaveBeenCalledTimes(1)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// Both deadlines are cancelled, so neither pane can record an expiry it did not earn.
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS * 2)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, FIRST_TAB_ID)).toBe(false)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, SECOND_TAB_ID)).toBe(false)
})
// The drain is re-entrant: `resumeSleepingAgentSessionsForWorktree` reaches `createTab`, zustand
// notifies with no queue, and the nested pass drains the same map the outer loop is still
// walking. One store write must still mean one replay per pane. (Not one deadline per pane: the
// re-park after a release always takes a fresh budget, whichever way this goes — what a second
// release actually costs is running a whole worktree resume sweep again off one frame.)
it('replays a pane once per store write even when an earlier replay re-enters the drain', () => {
const siblingReplay = vi.fn(() => {
// What the real replay does when the sweep still finds the pane undecided.
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, siblingReplay)
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
// Only `tabsByWorktree` and `ptyIdsByTabId` re-enter: the subscription's slice guard drops
// everything else, so a `clearSleepingAgentSession` write would never reach the drain.
useAppStore.setState({ tabsByWorktree: { ...useAppStore.getState().tabsByWorktree } })
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, siblingReplay)
publishBothHandles()
expect(siblingReplay).toHaveBeenCalledTimes(1)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
expect(vi.getTimerCount()).toBe(1)
})
// The other way the snapshot goes stale, and the one object identity cannot see: re-parking a
// STILL-PARKED pane mutates the waiter in place, so its `worktreeId` can move between the moment
// the drain judged it retracted and the moment it releases.
//
// Staged the only way production can reach it. A replay is
// `resumeSleepingAgentSessionsForWorktree` closed over ONE worktree and re-parks only under that
// worktree, so a waiter's worktree can only move when a DIFFERENT waiter's replay sweeps the
// workspace the row was adopted into. Here the second tab has already been re-keyed onto the
// canonical id — `canonicalizeTerminalSessionWorktreeId` re-keys `tabsByWorktree` and leaves the
// sleeping record naming the old one — so the first pane's sweep legitimately owns it, while the
// live waiter is still filed under the id its record named.
it('does not release on retraction evidence a mid-drain adoption has already made stale', () => {
const adoptedReplay = vi.fn()
const ADOPTING_WORKTREE_ID = 'repo-1::/workspace/adopted'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: {
[ADOPTING_WORKTREE_ID]: [
{ id: FIRST_TAB_ID, title: 'one' },
{ id: SECOND_TAB_ID, title: 'two' }
]
}
} as never)
// Parked first so the drain reaches it first: `Map` preserves insertion order, and this pane's
// replay is what makes the next entry's snapshot verdict stale. If that order ever inverted the
// test would fail rather than pass quietly — the second pane would release before the adoption.
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, ADOPTING_WORKTREE_ID, FIRST_TAB_ID, () => {
// The sweep for the adopting workspace, re-parking the pane it now owns. No store write: a
// sweep that parks every record it finds launches nothing, which is exactly this case.
parkUntilHostMirrorHandleLands(
ENVIRONMENT_ID,
ADOPTING_WORKTREE_ID,
SECOND_TAB_ID,
adoptedReplay
)
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, adoptedReplay)
// The frame that starts the drain. The second tab is absent from the worktree its waiter is
// filed under, so the snapshot reads retraction — evidence the adoption above makes obsolete
// before the release loop reaches it.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: { [FIRST_TAB_ID]: [`remote:${ENVIRONMENT_ID}@@term_1`] }
} as never)
expect(adoptedReplay).not.toHaveBeenCalled()
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
// The other entry into `releaseWaiter`. Here the throw would escape the timer callback instead of
// the store write, and the verdict must still be recorded — a pane whose replay failed has still
// used up its budget, and dropping the verdict re-parks it on a fresh one forever.
it('records the expiry of a pane whose replay throws and still frees the pane', () => {
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
throw new Error('replay blew up')
})
expect(() => vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)).not.toThrow()
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, FIRST_TAB_ID)).toBe(true)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
/**
* The fourth eviction trigger: a PUBLISHED HANDLE ends the gap episode its verdict measured.
*
* Why none of the other three reach it. The generation rule cannot: the #19647 change in this same
* stack stops recording `status: null` for an unreachable host, so `connectionChanged` no longer
* fires across an outage on one runtime. The tab-death rule cannot: the row stays published the
* whole time — it is the HANDLE that comes and goes, which is the definition of the gap. Teardown
* cannot: the environment is still here. And the read-time pane-identity check cannot, because the
* pane that reattaches to the SAME PTY is deliberately the same pane
* (`host-mirror-handle-gap-verdict-union.test.ts`, "answers for a genuine reattach").
*
* So a verdict outlives the gap it was about, and the NEXT gap on that pane gets no wait at all —
* #19735 with the bounded wait removed rather than merely shortened.
*
* Why this does not reopen the park/expire/replay loop the verdict exists to break: that loop is
* a handle that NEVER lands. A landed handle between two gaps is positive host evidence, and each
* wait is still individually bounded by the deadline.
*/
const ENV_ID = 'env-landed-handle'
const WORKTREE = 'repo-1::wt-landed'
const TAB_ID = 'web-terminal-landed'
const PANE_PTY_ID = `remote:${encodeURIComponent(ENV_ID)}@@term_1`
const initialAppStoreState = useAppStore.getState()
/** Publishes the row AND the layout binding that makes the pane unverifiable rather than dead. */
function publishRow(options: { handleLanded: boolean }): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: { [WORKTREE]: [{ id: TAB_ID, title: 't', ptyId: null }] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': PANE_PTY_ID }
}
},
ptyIdsByTabId: options.handleLanded ? { [TAB_ID]: [PANE_PTY_ID] } : {}
} as unknown as AppState)
}
describe('handle-gap verdict, landed-handle eviction', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
setRuntimeEnvironmentConnectionGenerationForTests(ENV_ID, 1)
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('retires the verdict when the pane it was about finally publishes its handle', () => {
publishRow({ handleLanded: false })
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, vi.fn())
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(true)
// Same connection, same pane, same layout binding — only the handle is new. The verdict's
// subject has answered, so the verdict is spent.
publishRow({ handleLanded: true })
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(false)
})
it('gives the next gap on that pane its own full wait', () => {
publishRow({ handleLanded: false })
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, vi.fn())
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
publishRow({ handleLanded: true })
// A later frame republishes the row ahead of its handle: a NEW gap on the same connection.
publishRow({ handleLanded: false })
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(false)
const replay = vi.fn()
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, replay)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
expect(replay).not.toHaveBeenCalled()
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
expect(replay).toHaveBeenCalledTimes(1)
})
it('a wait re-parked under a new worktree is released by that worktree, not the old one', () => {
// Adopting an orphaned terminal re-keys `tabsByWorktree` without re-keying the record, so the
// re-park hands the live wait a new worktree. Retraction evidence about the OLD one says
// nothing about the wait that is actually running.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: {
'wt-old': [{ id: TAB_ID, title: 't', ptyId: null }],
'wt-new': [{ id: TAB_ID, title: 't', ptyId: null }]
},
ptyIdsByTabId: {}
} as unknown as AppState)
parkUntilHostMirrorHandleLands(ENV_ID, 'wt-old', TAB_ID, vi.fn())
const replayAfterAdoption = vi.fn()
parkUntilHostMirrorHandleLands(ENV_ID, 'wt-new', TAB_ID, replayAfterAdoption)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: { 'wt-new': [{ id: TAB_ID, title: 't', ptyId: null }] }
} as unknown as AppState)
expect(replayAfterAdoption).not.toHaveBeenCalled()
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({ tabsByWorktree: {} } as unknown as AppState)
expect(replayAfterAdoption).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,481 @@
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
markHostSessionMirrorHydrated,
resetHostSessionMirrorHydrationForTests
} from '@/runtime/host-session-mirror-hydration'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The window this pins: a paired runtime publishes a workspace's tab rows and its PTY handles on
// separate frames, so there is a frame where the row exists and `ptyIdsByTabId` is still empty.
// An empty handle map for a row the host is still publishing is `unverifiable`, never `exited`
// (docs/reference/ssh-execution-boundary.md), so nothing may be resumed off it.
//
// HOW TO ASSERT ON THIS MODULE, because the obvious way cannot fail. "Did the waiter release" is
// NOT an observable here: a waiter released for the wrong reason is immediately re-parked by the
// replayed sweep, so the store, the record and the parked count all read identically one tick
// later. A mutation that released every waiter on any tab's handle survived twelve tests written
// that way. What a spurious release actually costs is the deadline — the re-park starts a fresh
// budget — so the assertion has to advance the clock: park, advance part of the budget, do the
// thing, then advance to the ORIGINAL deadline and require the pane to decide on schedule.
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
const WEB_TAB_ID = 'web-terminal-host-tab-1'
const SECOND_LEAF_ID = '33333333-3333-4333-8333-333333333333'
const SIBLING_LEAF_ID = '44444444-4444-4444-8444-444444444444'
const SECOND_TAB_ID = 'web-terminal-host-tab-2'
const RUNTIME_ENV_ID = 'env-handle-gap'
function makeRuntimeOwnedWorktree(): ReturnType<typeof makeCreatedAgentWorktree> {
return {
...makeCreatedAgentWorktree(),
createdWithAgent: undefined,
hostId: `runtime:${encodeURIComponent(RUNTIME_ENV_ID)}`
}
}
/** A published mirrored row: tab, layout leaf, and the leaf's host PTY binding. */
function seedMirroredWorkspace(worktree: ReturnType<typeof makeCreatedAgentWorktree>): void {
const state: Partial<AppState> = {
repos: [
{
id: 'repo-1',
path: path.join(path.sep, 'workspace', 'repo'),
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeWorktreeId: worktree.id,
activeView: 'terminal',
tabsByWorktree: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
[worktree.id]: [{ id: WEB_TAB_ID, title: 'Claude', ptyId: null } as never]
},
terminalLayoutsByTabId: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
[WEB_TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'remote:env-handle-gap@@term_1' }
} as never
},
// The gap itself: the row is published, its handle has not arrived.
ptyIdsByTabId: {},
sleepingAgentSessionsByPaneKey: {},
pendingStartupByTabId: {},
automaticAgentResumeClaimsByTabId: {},
agentStatusByPaneKey: {}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState(state as AppState)
}
/**
* The first frame of a SPLIT mirrored tab whose sibling surface is already `ready` while the
* record's own surface is still `pending-handle` and has never been bound.
*
* Why "never been bound" and not "went pending": `retainPendingTerminalBindings`
* (web-session-tabs-sync/terminal-build.ts) carries a pending surface's PRIOR binding forward, so a
* leaf that has ever held a handle keeps it across the gap and this shape cannot arise from one. It
* needs a cold start or a re-pair — no existing layout to retain from.
*
* No wait is armed here for a separate reason: `ptyIdsByTabId[tab]` is non-empty, so the pane reads
* decidable at the tab-granular gate before the per-pane wait is ever considered. That is the
* residual, and it is decided on the first frame.
*
* And not "the tab published a handle no leaf is bound to": `ptyIdsByTabId[tab]` is built from the
* very map written to `terminalLayoutsByTabId[tab].ptyIdsByLeafId`, so those two cannot disagree
* about which PTY ids exist.
*/
function seedSplitTabWithOnlySiblingReady(
worktree: ReturnType<typeof makeCreatedAgentWorktree>
): void {
seedMirroredWorkspace(worktree)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
terminalLayoutsByTabId: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
[WEB_TAB_ID]: {
root: {
type: 'split',
direction: 'row',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SIBLING_LEAF_ID }
},
activeLeafId: SIBLING_LEAF_ID,
expandedLeafId: null,
// Only the ready sibling is bound; the record's leaf has never held a handle.
ptyIdsByLeafId: { [SIBLING_LEAF_ID]: 'remote:env-handle-gap@@term_sibling' }
} as never
},
ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_sibling'] }
} as never)
}
/** A second published mirrored row in the same environment, with its own leaf binding. */
function seedSecondMirroredPane(worktreeId: string): void {
const before = useAppStore.getState()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: {
[worktreeId]: [
...(before.tabsByWorktree[worktreeId] ?? []),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal names every field this suite reads; the cast only supplies the rest of the declared shape.
{ id: SECOND_TAB_ID, title: 'Claude 2', ptyId: null } as never
]
},
terminalLayoutsByTabId: {
...before.terminalLayoutsByTabId,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
[SECOND_TAB_ID]: {
root: { type: 'leaf', leafId: SECOND_LEAF_ID },
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SECOND_LEAF_ID]: 'remote:env-handle-gap@@term_2' }
} as never
}
} as never)
}
/** The capture the reported flow produces: recorded mid-turn, so it is active work, not history. */
function seedActiveSleepingRecordFor(
worktreeId: string,
tabId: string,
leafId: string,
sessionId: string
): string {
const paneKey = makePaneKey(tabId, leafId)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
...useAppStore.getState().sleepingAgentSessionsByPaneKey,
[paneKey]: {
paneKey,
tabId,
worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: sessionId },
connectionId: null,
prompt: '',
state: 'working',
capturedAt: 1000,
updatedAt: 1000,
terminalTitle: 'Claude',
origin: 'live'
}
}
} as never)
return paneKey
}
function seedActiveSleepingRecord(worktreeId: string): string {
return seedActiveSleepingRecordFor(worktreeId, WEB_TAB_ID, LEAF_ID, 'handle-gap-session')
}
describe('resume across the mirror handle gap', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
resetHostSessionMirrorHydrationForTests()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
// Why first: the store reset below retracts every row, which would replay a still-parked wait.
resetHostMirrorHandleGapWaitsForTests()
useAppStore.setState(initialAppStoreState, true)
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.useRealTimers()
})
it('does not resume a published mirrored pane whose handle has not landed yet', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
// The rows have arrived; only the handles are outstanding.
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
const launched = resumeSleepingAgentSessionsForWorktree(worktree.id)
const after = useAppStore.getState()
expect(launched).toBe(0)
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
expect(Object.keys(after.pendingStartupByTabId)).toHaveLength(0)
// The record survives: the next frame carries the handle and decides for real.
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
// And something is armed to decide it — a hold with nothing armed is the defect, not the fix.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
// The counterweight to the park, and the reason the hydration short-circuit could not simply be
// dropped: a pane with nothing outstanding must still resume. Here no leaf of the published row
// binds a PTY this environment minted, so there is no handle on its way and no wait to arm —
// parking would be the latch-that-never-releases defect, since mirror settlement has already run
// and will not replay the sweep a second time.
it('still resumes a published row no leaf of which binds this environment', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
terminalLayoutsByTabId: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
[WEB_TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {}
} as never
}
} as never)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
})
// The three exits of the per-pane park. A park with no bounded release is the
// latch-that-never-releases defect, so each one must replay the sweep.
it("releases when the pane's own handle lands and keeps the pane it now owns", () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_1'] } })
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// The released waiter must not fire again at the deadline.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const after = useAppStore.getState()
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
// KNOWN RESIDUAL, pinned as current behaviour rather than as desired behaviour. The gate this
// wait sits behind is tab-granular (`host-mirrored-pane-liveness.ts`: any published handle for
// the tab makes the pane decidable), while everything below it is leaf-aware. A split tab whose
// sibling surface is `ready` while this one is still `pending-handle` therefore reads decidable,
// no wait is armed at all, and the sweep resumes a pane the host has not answered for — #19735's
// own shape, narrowed to a split tab's first frame.
//
// It is not closable inside this module: such a leaf has NO binding, and the binding is what
// names the pane in a verdict, so a leaf-keyed wait has nothing to key on. It needs the
// per-surface `pending-handle` status the host publishes (runtime-mobile-session-projection.ts)
// and the client consumes without retaining per leaf. Tracked separately; this case exists so
// the residual cannot be mistaken for a covered one.
it('resumes a pending leaf when a sibling leaf of the same tab holds the only handle', () => {
const worktree = makeRuntimeOwnedWorktree()
seedSplitTabWithOnlySiblingReady(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1)
// The residual in one assertion: nothing was ever parked for this pane.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
const after = useAppStore.getState()
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
.map((tab) => tab.id)
.filter((id) => id !== WEB_TAB_ID)
expect(resumeTabIds).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('releases when the host retracts the row and resumes into a fresh tab', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
useAppStore.setState({ tabsByWorktree: { [worktree.id]: [] } })
const after = useAppStore.getState()
const tabs = after.tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[tabs[0]!.id]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('releases at the deadline and resumes rather than holding the pane forever', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
vi.advanceTimersByTime(1)
const after = useAppStore.getState()
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
.map((tab) => tab.id)
.filter((id) => id !== WEB_TAB_ID)
expect(resumeTabIds).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('keeps the original deadline when a second sweep re-parks the same pane', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
// A re-activation mid-wait must not push the decision out another full budget.
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
it('re-arms the wait after a reconnect instead of inheriting the expired verdict', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
// A host restart: the same row, a new connection, its handle unknown again.
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
setRuntimeEnvironmentConnectionGenerationForTests(RUNTIME_ENV_ID, 1)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
// Why this is not the test above: there the wait had already expired before the reconnect, so
// the stale verdict was a map entry. Here the wait is still armed when the generation moves, and
// its deadline then fires on a connection that has had no chance at all to publish the handle.
it('does not let a wait armed on the previous connection decide the new one', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
// The host reconnects one millisecond before the wait's own deadline.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
setRuntimeEnvironmentConnectionGenerationForTests(RUNTIME_ENV_ID, 1)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
vi.advanceTimersByTime(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
// Re-armed, not held: the new connection gets its own budget and then decides.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
it('releases only the pane whose handle landed when two panes share the environment', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedSecondMirroredPane(worktree.id)
const firstPaneKey = seedActiveSleepingRecordFor(worktree.id, WEB_TAB_ID, LEAF_ID, 'session-1')
const secondPaneKey = seedActiveSleepingRecordFor(
worktree.id,
SECOND_TAB_ID,
SECOND_LEAF_ID,
'session-2'
)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_1'] } })
// The first pane owns its live PTY; the second is still undecided, not resumed.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
const after = useAppStore.getState()
expect(after.sleepingAgentSessionsByPaneKey[firstPaneKey]).toBeDefined()
expect(after.sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeDefined()
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
// Why the clock matters: releasing the second pane here and letting the replay re-park it
// would look identical right now and silently restart its budget. Its own deadline still has
// to land on the original schedule.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeUndefined()
})
it('does not release or reschedule a park because another environment published a handle', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
useAppStore.setState({
ptyIdsByTabId: { 'web-terminal-other-env-tab': ['remote:env-other@@term_1'] }
})
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
// The unrelated handle must not have restarted this pane's budget.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('leaves no waiter or timer behind when the environment tears its rows down mid-park', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
// Teardown drops every row the environment owned.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({ tabsByWorktree: {}, terminalLayoutsByTabId: {} } as never)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// Nothing may still be scheduled against the torn-down environment.
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
countParkedHostMirrorHandleGapPanesForTests,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The retention suite that the reconciled verdict loop replaced carried one assertion the split
// suites did not: the store subscription is held for exactly as long as something needs it.
//
// Measured rather than assumed, because half of it turned out to be covered already:
// - RETAIN direction (drop the verdict term from `stopStoreSubscriptionIfIdle`, so a verdict
// with no waiter behind it loses the subscription its drain needs): already caught, by
// host-mirror-handle-gap-landed-handle.test.ts. Two failures there without this file.
// - RELEASE direction (never release the subscription at all): caught by NOTHING else. With
// `stopStoreSubscriptionIfIdle` neutered, the three cases below are the only failures in the
// handle-gap and session-tabs tree: 326 tests across the other 37 files still pass. A leaked
// subscription rescans every parked pane on every store write for the life of the session and
// nothing else notices.
//
// So this file exists for the release direction; the retain cases are here because the two belong
// in one place, not because they were missing. `stopStoreSubscriptionIfIdle` counts VERDICTS as
// well as waiters -- the landed-handle drain observes a transition no waiter is parked for -- and
// that is exactly the term the reconcile moved, so both directions are worth holding still.
//
// Asserted through a spy rather than a new test-only export: whether the module is subscribed is
// already observable at the store boundary, and the production surface should not grow to say so.
const ENVIRONMENT_ID = 'env-subscription'
const OTHER_ENVIRONMENT_ID = 'env-other'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const initialAppStoreState = useAppStore.getState()
let unsubscribeCalls: number
let subscribeCalls: number
function publishPaneAndPark(environmentId: string, tabId: string): void {
const state = useAppStore.getState()
const published = state.tabsByWorktree[WORKTREE_ID] ?? []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [...published.filter((tab) => tab.id !== tabId), { id: tabId, title: tabId }]
},
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
[tabId]: {
root: { type: 'leaf', leafId: `leaf-${tabId}` },
activeLeafId: `leaf-${tabId}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${tabId}`]: `remote:${environmentId}@@term_${tabId}` }
}
}
} as never)
parkUntilHostMirrorHandleLands(environmentId, WORKTREE_ID, tabId, () => {})
}
/** Lands the pane's handle, which is what both releases a waiter and retires a verdict. */
function landHandle(tabId: string): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: { ...useAppStore.getState().ptyIdsByTabId, [tabId]: [`pty-${tabId}`] }
} as never)
}
describe('host-mirror handle-gap store subscription lifetime', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
unsubscribeCalls = 0
subscribeCalls = 0
const realSubscribe = useAppStore.subscribe.bind(useAppStore)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the subscriber is invoked with the store state pair; the narrowed listener type is what this suite asserts on.
vi.spyOn(useAppStore, 'subscribe').mockImplementation(((listener: never) => {
subscribeCalls += 1
const unsubscribe = realSubscribe(listener)
return () => {
unsubscribeCalls += 1
unsubscribe()
}
}) as never)
})
afterEach(() => {
vi.restoreAllMocks()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('holds exactly one subscription across several parked panes', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
publishPaneAndPark(ENVIRONMENT_ID, 'tab-b')
publishPaneAndPark(OTHER_ENVIRONMENT_ID, 'tab-c')
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(3)
expect(subscribeCalls).toBe(1)
expect(unsubscribeCalls).toBe(0)
})
it('releases the subscription once the last waiter leaves and no verdict remains', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
publishPaneAndPark(ENVIRONMENT_ID, 'tab-b')
landHandle('tab-a')
expect(unsubscribeCalls).toBe(0)
landHandle('tab-b')
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(unsubscribeCalls).toBe(1)
})
it('keeps the subscription for a verdict with no waiter parked behind it', () => {
// The case the reconcile introduced: the waiter is gone, but the landed-handle drain still has
// a verdict to watch. Counting only waiters here would drop the subscription that drain needs.
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(unsubscribeCalls).toBe(0)
})
it('releases the subscription when the last verdict is cleared by teardown', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(unsubscribeCalls).toBe(0)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(unsubscribeCalls).toBe(1)
})
it('re-subscribes rather than reusing a dropped subscription', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
landHandle('tab-a')
expect(unsubscribeCalls).toBe(1)
publishPaneAndPark(ENVIRONMENT_ID, 'tab-d')
expect(subscribeCalls).toBe(2)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
})
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import { clearWebSessionTabsTrackingForEnvironment } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The orphan class no recording-driven prune can reach. Both existing rules — stale generation and
// tab death — run only when a verdict is RECORDED, so an environment that is removed and never
// expires another pane keeps its rows for the life of the session.
const ENVIRONMENT_ID = 'env-torn-down'
const OTHER_ENVIRONMENT_ID = 'env-survivor'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const initialAppStoreState = useAppStore.getState()
function parkAndExpire(environmentId: string, tabId: string): void {
// Rows ACCUMULATE. Replacing them would unpublish the panes parked earlier, and the tab-death
// rule would then legitimately sweep their verdicts before teardown was ever reached — this
// suite is about a class no recording-driven prune can reach, so every pane here stays live.
const state = useAppStore.getState()
const published = state.tabsByWorktree[WORKTREE_ID] ?? []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [...published.filter((tab) => tab.id !== tabId), { id: tabId, title: tabId }]
},
// A verdict names its PANE by the environment-minted PTY held at park time, so a fixture with
// no layout binding records '' and the verdict refuses to answer. Bind per environment: one
// shared environment id would filter to '' for every other environment's pane.
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
[tabId]: {
root: { type: 'leaf', leafId: `leaf-${tabId}` },
activeLeafId: `leaf-${tabId}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${tabId}`]: `remote:${environmentId}@@term_${tabId}` }
}
}
} as never)
parkUntilHostMirrorHandleLands(environmentId, WORKTREE_ID, tabId, () => {})
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
}
describe('host-mirror handle-gap verdicts across environment teardown', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('drops the torn-down environments verdicts and keeps every other environments', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-2')
parkAndExpire(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(3)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(hasHostMirrorHandleWaitExpired(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')).toBe(
true
)
})
// Matches `clearHostSessionMirrorHydration`: a re-pair replaces the connection's evidence, it
// does not cancel the recovery this client still owes the pane. Clearing the waiter here would
// silently drop a parked resume sweep that nothing else will replay.
it('leaves a parked waiter alone, cancelling only the verdicts', () => {
const replay = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: { [WORKTREE_ID]: [{ id: 'web-terminal-host-tab-9', title: 'nine' }] }
} as never)
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, 'web-terminal-host-tab-9', replay)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(replay).toHaveBeenCalledTimes(1)
})
// The live wiring: session-tabs tracking teardown is the only caller that fires for an
// environment that is going away, so the hook has to hang off it or the rows never drain.
it('drains through the session-tabs tracking teardown for the environment', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
parkAndExpire(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(2)
clearWebSessionTabsTrackingForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(hasHostMirrorHandleWaitExpired(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')).toBe(
true
)
})
// Why the stranded row was inert rather than dangerous, pinned so nobody "optimises" the
// generation advance away: removing an environment advances its connection generation, so a
// verdict left behind can never match again even if the id returns.
it('cannot match again after the environment returns on a new generation', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, 'web-terminal-host-tab-1')).toBe(true)
setRuntimeEnvironmentConnectionGenerationForTests(ENVIRONMENT_ID, 1)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, 'web-terminal-host-tab-1')).toBe(false)
})
})
@@ -0,0 +1,247 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
/**
* The UNION suite for `expiredGenerationByPane`.
*
* Three agents changed this one map on three branches and each verified only their own. These
* cases exist because nothing else proves the rules compose: individually-correct rules whose
* interaction nobody tested is the exact failure this was looking for.
*
* Four orphan classes, and what covers each:
* A tab churn on a LIVE environment tab-death rule, recording environment only
* B REMOVED environment clearHostMirrorHandleGapVerdictsForEnvironment
* C cross-environment QUIESCENCE generation rule, per key, every environment
* D REUSED tab id read-time pane identity, NOT a prune
*
* D is the one that needed no new trigger: every trigger the other three own fires downstream of
* the moment it needs. The verdict instead carries the PTY binding its pane held AT PARK TIME and
* only answers for a pane that still holds it.
*
* Plus the properties no rule may break: the verdict stays sticky enough to break the
* park/expire/replay loop, a genuine reattach still inherits, and no rule evicts a verdict a live
* pane still needs.
*/
const ENV_A = 'env-union-a'
const ENV_B = 'env-union-b'
const ENV_C = 'env-union-c'
const WORKTREE = 'repo-1::wt-union'
const initialAppStoreState = useAppStore.getState()
/** Which environment minted each pane's PTY; the binding only counts for its own environment. */
const ENV_OF_TAB: Record<string, string> = {
a1: ENV_A,
a2: ENV_A,
reused: ENV_A,
b1: ENV_B,
c1: ENV_C
}
/** Publishes rows AND the layout PTY binding each pane holds — the binding is the pane's identity. */
function setLiveTabs(tabIds: string[], ptyByTabId: Record<string, string> = {}): void {
const layouts: Record<string, unknown> = {}
for (const id of tabIds) {
const ptyId = ptyByTabId[id] ?? `remote:${ENV_OF_TAB[id] ?? ENV_A}@@term_${id}`
layouts[id] = {
root: { type: 'leaf', leafId: `leaf-${id}` },
activeLeafId: `leaf-${id}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${id}`]: ptyId }
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
tabsByWorktree: { [WORKTREE]: tabIds.map((id) => ({ id, title: id, ptyId: null })) },
terminalLayoutsByTabId: layouts,
ptyIdsByTabId: {}
} as unknown as AppState)
}
function parkAndExpire(environmentId: string, tabId: string): void {
parkUntilHostMirrorHandleLands(environmentId, WORKTREE, tabId, () => {})
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
}
describe('handle-gap verdict map, all rules on one tree', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.useRealTimers()
})
it('handles all four orphan classes simultaneously', () => {
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
}
setLiveTabs(['a1', 'a2', 'b1', 'c1', 'reused'])
// A: tab churn on a live environment. a1 expires, then its tab closes.
parkAndExpire(ENV_A, 'a1')
// B: a whole environment that will be removed.
parkAndExpire(ENV_B, 'b1')
// C: an environment that will reconnect and then never expire another pane.
parkAndExpire(ENV_C, 'c1')
// D: a tab id that will be retracted and republished under the same id.
parkAndExpire(ENV_A, 'reused')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(4)
// C reconnects and goes quiet. B's environment is removed outright.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_C, 2)
clearHostMirrorHandleGapVerdictsForEnvironment(ENV_B)
// A's tab closes; the reused id is retracted and republished as a DIFFERENT pane, which binds
// a PTY the host newly minted. That new binding is what makes it a different pane, not the id.
setLiveTabs(['a2', 'reused'], { reused: `remote:${ENV_A}@@term_freshly_minted` })
parkAndExpire(ENV_A, 'a2')
// A drained: a1's row is gone and env-a recorded again, so the tab-death rule swept it.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
// B drained: by teardown, which is the only trigger that fires for a removed environment.
expect(hasHostMirrorHandleWaitExpired(ENV_B, 'b1')).toBe(false)
// C: env-c reconnected at :109, so this read is false on the read-time generation gate alone
// and says nothing about whether the drain ran. The drain is what the COUNT below proves — it
// is the only assertion here that distinguishes "retired" from "stranded but unreachable".
expect(hasHostMirrorHandleWaitExpired(ENV_C, 'c1')).toBe(false)
// D is closed, and NOT by a prune. No trigger any rule above owns fires at the right moment:
// the tab-death predicate stops matching once the id is live again, teardown is the wrong
// event, and no waiter observes the retraction because a pane holding a verdict never parks.
// It is closed at READ time instead — the verdict names the pane it was about, so a pane that
// binds a newly minted PTY does not answer to it and serves its own wait.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'reused')).toBe(false)
// Only the two live verdicts survive: a2's and the stranded reused-id row.
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(2)
})
it('answers for a genuine reattach that still holds the same PTY', () => {
// The verdict follows the PTY, not the tab id. A pane that reattaches to the SAME environment
// PTY is the same pane, so it must inherit — otherwise the identity check would have quietly
// removed the loop-breaker for every reattach.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkAndExpire(ENV_A, 'a1')
setLiveTabs([])
setLiveTabs(['a1'])
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('records the binding the pane held at PARK time, not at expiry', () => {
// The mutation this kills: reading the binding inside `recordExpiredWait` from the store
// instead of from the waiter. A pane replaced mid-wait leaves the original waiter running to
// term, and an expiry-time read would attribute the verdict to whoever holds the id by then —
// handing the new pane a wait it never served. Three earlier cases all survived that bug;
// only rebinding BETWEEN park and expire distinguishes the two implementations.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkUntilHostMirrorHandleLands(ENV_A, WORKTREE, 'a1', () => {})
setLiveTabs(['a1'], { a1: `remote:${ENV_A}@@term_replacement` })
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
// The replacement pane never served this wait, so it must not inherit its verdict.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
})
it('refuses to answer on an empty binding, which is a match value and not a null', () => {
// '' is what `paneBindingFor` returns when no leaf holds an environment-minted PTY. Two
// different panes both reading '' would compare EQUAL and inherit, which is the reused-tab-id
// shape again. Measured unreachable through the production park path rather than assumed: the
// only route into `parkUntilHostMirrorHandleLands` is `kind: 'handle'`, which
// `findUnhydratedHostMirrorForPane` reports only when `tabHoldsEnvironmentPtyBinding`
// (host-mirrored-pane-liveness.ts:28-31) finds a match — the SAME `terminalLayoutsByTabId`
// map through the SAME `parseRemoteRuntimePtyId` predicate `paneBindingFor` uses, so a pane
// that would bind '' never parks. It must still refuse rather than match, because that
// coupling is two functions in two files and nothing enforces it.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'], { a1: 'remote:some-other-env@@term_1' })
parkAndExpire(ENV_A, 'a1')
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
})
it('keeps a verdict sticky enough to break the park/expire/replay loop', () => {
// The verdict exists to stop a pane re-parking forever. If any rule evicted it while the pane
// is live and its connection current, the wait would rearm on a fresh budget every replay.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkAndExpire(ENV_A, 'a1')
for (let replay = 0; replay < 20; replay += 1) {
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
parkAndExpire(ENV_A, 'a1')
}
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('never evicts a live pane verdict, whichever environment sweeps', () => {
// env-b is the discriminator, and it is the only assertion here that is not a control: its row
// goes absent at the moment env-a records, so widening the tab-death rule past the recording
// environment deletes a verdict whose pane is merely mid-republish. env-a's and env-c's rows
// are published throughout and hold under every candidate rule.
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
}
setLiveTabs(['a1', 'a2', 'b1', 'c1'])
parkAndExpire(ENV_A, 'a1')
parkAndExpire(ENV_B, 'b1')
parkAndExpire(ENV_C, 'c1')
// env-b is briefly rowless mid-rehydration while env-a sweeps. Row absence is transient, so
// this must not be read as retraction for an environment other than the one recording.
setLiveTabs(['a1', 'a2', 'c1'])
parkAndExpire(ENV_A, 'a2')
setLiveTabs(['a1', 'a2', 'b1', 'c1'])
expect(hasHostMirrorHandleWaitExpired(ENV_B, 'b1')).toBe(true)
expect(hasHostMirrorHandleWaitExpired(ENV_C, 'c1')).toBe(true)
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('holds the verdict map at one live row per environment under churn', () => {
for (let round = 0; round < 300; round += 1) {
const environmentId = [ENV_A, ENV_B, ENV_C][round % 3]!
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, round + 1)
// Bind each round's pane to the environment that is recording it. No assertion here reads
// `paneBinding` and neither prune rule inspects it, so this changes no outcome — but falling
// back to env-a stored the empty match value on two rounds in three, and a fixture that
// models a state the production park path cannot reach is not churn worth running.
setLiveTabs([`tab-${round}`], { [`tab-${round}`]: `remote:${environmentId}@@term_${round}` })
parkAndExpire(environmentId, `tab-${round}`)
}
// The assertion the loop exists for, and it has to come BEFORE teardown: the clear below
// deletes every key in the map by construction, so `toBe(0)` after it holds whether the drains
// work or are deleted outright. 300 expiries must leave one live verdict per environment.
// What this pins is that the prune loop runs AT ALL — without it the map holds 300. It does
// not isolate which rule prunes: with one tab live per round the generation rule and the
// tab-death rule each sweep the recording environment's predecessor on their own, so removing
// either alone still reads 3. The generation rule is separately isolated by the count in
// `handles all four orphan classes simultaneously`, where only it can retire env-c's row.
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(3)
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
clearHostMirrorHandleGapVerdictsForEnvironment(environmentId)
}
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,405 @@
import { useAppStore } from '@/store'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from '@/runtime/web-session-tab-rpc-timeout'
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
/**
* Per-pane park for the frame between a host's tab rows and its PTY handles.
*
* Why: mirror hydration says "the rows arrived", not "this pane's liveness is
* decidable" — the handle lands one relay round trip later. A pane whose leaf
* is still bound to a PTY of the same environment, with no published handle,
* is `unverifiable` (docs/reference/ssh-execution-boundary.md); resuming on it
* forked a session the host was still running (#19735).
*
* The wait is bounded because mirror settlement has already happened and will
* not replay a parked sweep again. Three exits, each replaying the sweep:
* - the pane's own handle lands (`ptyIdsByTabId[tabId]` non-empty);
* - the row is retracted (the host has spoken: the pane is gone);
* - the deadline expires. A handle that has not landed within the RPC budget
* is not coming on this connection, so the pane is released to ordinary
* recovery: a resume after a bounded wait is defensible, an indefinite hold
* is the latch-that-never-releases defect. A reconnect bumps the connection
* generation and arms a fresh wait.
*
* Sustained reconnect churn can therefore hold a pane parked indefinitely: each reconnect voids the
* in-flight verdict and grants a fresh full budget. That is CORRECT, not the defect above. Under
* churn the pane's liveness genuinely is unverifiable, and `docs/reference/ssh-execution-boundary.md`
* forbids resolving unverifiable to `exited`. It has the shape of a latch that never releases, so
* do not "fix" it by letting a verdict from one connection decide another — that is #19735.
*/
export const HOST_MIRROR_HANDLE_GAP_DEADLINE_MS = WEB_SESSION_TAB_RPC_TIMEOUT_MS
type HandleGapWaiter = {
worktreeId: string
tabId: string
/** Connection generation the wait was armed on; its verdict is void on any other. */
generation: number
/** Which PANE this wait is about, captured at park time; see ExpiredHandleGapVerdict. */
paneBinding: string
deadline: ReturnType<typeof setTimeout>
run: () => void
}
type HandleGapStoreState = Pick<
ReturnType<typeof useAppStore.getState>,
'ptyIdsByTabId' | 'tabsByWorktree'
>
const waitersByPane = new Map<string, HandleGapWaiter>()
/**
* Connection generation whose wait already expired for the pane.
*
* FOUR drains, with four different triggers. Getting the scopes right is the whole design; see
* `recordExpiredWait` for why the first two must NOT share a scope.
* - superseded generation: per key, EVERY environment. Runs on any recording, anywhere.
* - dead tab row: the recording environment ONLY. Runs on a recording in that environment.
* - removed environment: `clearHostMirrorHandleGapVerdictsForEnvironment`, on teardown. The only
* trigger that fires at all for an environment that will never record again. A row stranded
* there is inert — removal advances the generation, so it can never match — so that one is a
* leak fix, not a correctness fix.
* - PUBLISHED HANDLE: `retireVerdictsWithLandedHandles`, from the store subscription. The gap a
* verdict measured is over once its pane publishes a handle, so the NEXT gap must get its own
* wait. The other three provably cannot reach this: the generation no longer moves across an
* outage on one runtime (#19647, same stack), the row stays published the whole time — it is
* the HANDLE that comes and goes — the environment is still here, and the read-time pane
* identity below deliberately lets the same PTY inherit. It is the only drain that needs the
* subscription to outlive the waiters, which is why `stopStoreSubscriptionIfIdle` counts
* verdicts too.
*
* A FIFTH class is covered but NOT by any of those drains: a retracted tab id republished as a
* different pane, which would inherit the old pane's verdict and skip its own wait — the #19735
* direction rather than a longer hold. No trigger can reach it, and the reason is worth keeping:
* the dead-row predicate stops matching once the id is live again, teardown is the wrong event,
* and a pane holding a verdict never parks, so no waiter is there to observe the retraction. It is
* closed at READ time instead, by `hasHostMirrorHandleWaitExpired` comparing the verdict's
* park-time `paneBinding` — a pane that binds a newly minted PTY does not answer to a verdict
* about its predecessor. Pinned as class D in host-mirror-handle-gap-verdict-union.test.ts; do not
* delete that case.
*
* KNOWN LEAK, deliberately not drained: a verdict whose row the host retracts for good on an
* environment that stays paired and never records again. The generation has not moved, teardown
* never fires, the retracted row can never publish a handle, and the tab-death rule only runs from
* inside a later recording. That entry outlives the session, and because
* `stopStoreSubscriptionIfIdle` counts verdicts, so does the store subscription — a no-op rescan on
* every write to the two `HandleGapStoreState` slices above. It cannot answer: the STORED binding is
* non-empty, so the `''` early return below does not catch it; what does is the compare against a
* fresh `paneBindingFor`, which reads '' for a row that is gone. So it costs work, not correctness.
* The obvious drain — drop a verdict whose binding no longer matches — is NOT safe: it would break
* the genuine reattach, where
* the binding goes away and comes back and the verdict must still answer
* (host-mirror-handle-gap-verdict-union.test.ts, "answers for a genuine reattach").
*
* The PUBLISHED HANDLE drain does not close that class and must not be read as closing it: it
* needs the row to stay published throughout, and that class needs the row to go away. Read-time
* identity separates two panes behind one tab id; the drain separates two gaps on one pane. They
* look adjacent and are orthogonal — mutation kills them with disjoint tests.
*
* Why this comment block is worth re-reading against the code rather than trusting: the paragraph
* above it spent one commit asserting this class was still open and demanding a trigger that had
* just been replaced by the read-time check, while the test it named as its pin said the opposite.
* Several agents change this map in parallel and the invariants move faster than the prose, so
* when the two disagree the test file is the one that ran.
*/
type ExpiredHandleGapVerdict = {
generation: number
/** Sorted environment-minted PTY ids the tab's leaves held AT PARK TIME; '' when none. */
paneBinding: string
}
const expiredGenerationByPane = new Map<string, ExpiredHandleGapVerdict>()
let unsubscribeStore: (() => void) | null = null
function paneWaitKey(environmentId: string, tabId: string): string {
return `${environmentId}\0${tabId}`
}
/**
* The environment-minted PTY ids this tab's leaves are bound to, as one comparable string.
*
* Read from the layout, not `ptyIdsByTabId`: during the handle gap the published-handle map is
* empty by definition — that is the gap — while the layout binding is what
* `tabHoldsEnvironmentPtyBinding` already uses to call the pane unverifiable rather than dead.
*/
function paneBindingFor(tabId: string, environmentId: string): string {
const bindings = useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
return Object.values(bindings)
.filter(
(ptyId): ptyId is string =>
typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId)?.environmentId === environmentId
)
.sort()
.join('')
}
/** True once the deadline fired for THIS pane on the current connection. */
export function hasHostMirrorHandleWaitExpired(environmentId: string, tabId: string): boolean {
const verdict = expiredGenerationByPane.get(paneWaitKey(environmentId, tabId))
if (verdict === undefined || verdict.paneBinding === '') {
// Why '' never answers: it is a MATCH VALUE, not a null. Two different panes that both hold no
// environment-minted PTY compare equal, which is the reused-tab-id inheritance this check
// exists to stop, in a narrower window. Unreachable through the production park path —
// `findUnhydratedHostMirrorForPane` only reports `kind: 'handle'` when
// `tabHoldsEnvironmentPtyBinding` finds a binding, reading the same map through the same
// predicate as `paneBindingFor` — and pinned by the coupling test in
// host-mirror-handle-gap-verdict-union.test.ts. Refusing costs a re-park, which is the
// conservative direction, so the pair stays safe even if those two reads ever drift apart.
return false
}
return (
verdict.generation === getRuntimeEnvironmentConnectionGeneration(environmentId) &&
// Why this and not the key alone: the key is a tab id, and the pane behind it can be replaced.
verdict.paneBinding === paneBindingFor(tabId, environmentId)
)
}
function liveTabIds(): Set<string> {
const tabIds = new Set<string>()
for (const tabs of Object.values(useAppStore.getState().tabsByWorktree)) {
for (const tab of tabs) {
tabIds.add(tab.id)
}
}
return tabIds
}
function recordExpiredWait(environmentId: string, key: string): void {
const generation = getRuntimeEnvironmentConnectionGeneration(environmentId)
// TWO rules with DIFFERENT scopes, deliberately. Flattening them to one scope is wrong either
// way round, and both wrong shapes were independently written before this was reconciled.
const prefix = `${environmentId}\0`
const liveTabs = liveTabIds()
for (const [staleKey, stale] of expiredGenerationByPane) {
// GENERATION, judged per key across EVERY environment. `hasHostMirrorHandleWaitExpired`
// compares a row against its own environment's CURRENT generation, so a row whose generation
// has moved can never return true for anyone. Retiring it cannot cost a reader a verdict,
// whoever owns it. Scoped to the recording environment, an environment that reconnects and
// then goes quiet strands its rows forever.
const staleEnvironmentId = staleKey.slice(0, staleKey.indexOf('\0'))
if (stale.generation !== getRuntimeEnvironmentConnectionGeneration(staleEnvironmentId)) {
expiredGenerationByPane.delete(staleKey)
continue
}
// TAB DEATH, this environment ONLY. Unlike a generation, row absence is transient: a sibling
// mid-republish has no rows for a frame and would lose a verdict its pane still needs. What
// licenses the inference here is that the recording pane's own row is published right now —
// the deadline only records while its waiter is parked — which establishes that THIS
// environment has a published row. It does not establish that it has finished republishing,
// so do not widen this further: a host that has published p1 but not yet p2 can still cost p2
// its verdict. That residual is conservative — drop, re-park, hold longer, never resume early.
if (staleKey.startsWith(prefix) && !liveTabs.has(staleKey.slice(prefix.length))) {
expiredGenerationByPane.delete(staleKey)
}
}
// Why the waiter's park-time binding and not a fresh read: this verdict is about the pane whose
// wait just ran out. Re-reading here would attribute it to whatever holds the id NOW, handing a
// pane that replaced it mid-wait a verdict it never served. The caller must therefore record
// BEFORE `releaseWaiter` deletes the entry; the union suite pins that ordering.
// The `?? ''` is unreachable solely because of the record-before-release ordering above it. The
// caller's generation gate LOOKS like a second guard on it and is not: drop the ordering and that
// gate stops recording anything at all rather than admitting ''. It pins a different property
// (reconnect-void, host-mirror-handle-gap-resume.test.ts). Both are load-bearing, for different
// reasons — do not collapse them as redundant.
expiredGenerationByPane.set(key, {
generation,
paneBinding: waitersByPane.get(key)?.paneBinding ?? ''
})
// The landed-handle drain has to keep watching after this waiter is released.
startStoreSubscription()
}
/**
* Retires the verdict of any pane whose handle is now published.
*
* A published handle is the mirror having spoken for the pane, so the gap the verdict measured is
* over. Read from `ptyIdsByTabId`, deliberately NOT from the layout `paneBinding` — the binding is
* the pane's IDENTITY and holds across the gap by design, which is exactly why it cannot see this.
*/
function retireVerdictsWithLandedHandles(state: HandleGapStoreState): void {
for (const key of expiredGenerationByPane.keys()) {
const tabId = key.slice(key.indexOf('\0') + 1)
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
expiredGenerationByPane.delete(key)
}
}
}
function stopStoreSubscriptionIfIdle(): void {
// Verdicts count: the landed-handle drain observes a transition no waiter is parked for.
if (waitersByPane.size === 0 && expiredGenerationByPane.size === 0 && unsubscribeStore) {
unsubscribeStore()
unsubscribeStore = null
}
}
function releaseWaiter(key: string): void {
const waiter = waitersByPane.get(key)
if (!waiter) {
return
}
clearTimeout(waiter.deadline)
waitersByPane.delete(key)
stopStoreSubscriptionIfIdle()
try {
waiter.run()
} catch (error) {
// Why: one write releases every due pane, and the drain runs inside the store subscriber. The
// panes in it are strangers to each other and to the frame that published the handle, so an
// unguarded replay throw both strands every pane queued behind it and surfaces at the mirror
// apply's own `setState`. The pane is already unparked here; only its replay is lost.
console.warn('[host-mirror-handle-gap] parked resume replay failed:', error)
}
}
function waiterIsReleased(waiter: HandleGapWaiter, state: HandleGapStoreState): boolean {
if ((state.ptyIdsByTabId[waiter.tabId]?.length ?? 0) > 0) {
return true
}
const tabs = state.tabsByWorktree[waiter.worktreeId] ?? []
return !tabs.some((tab) => tab.id === waiter.tabId)
}
function releaseDueWaiters(state: HandleGapStoreState): void {
// Why: drain from a snapshot — a replay can re-park the pane, and that new
// waiter belongs to the next store write, not this one.
const due: [string, HandleGapWaiter][] = []
for (const [key, waiter] of waitersByPane) {
if (waiterIsReleased(waiter, state)) {
due.push([key, waiter])
}
}
// TWO guards, because a replay earlier in this loop reaches `createTab` and so re-enters this
// drain through zustand, which notifies with no queue. Each guard catches a different way the
// snapshot goes stale mid-loop, and neither covers the other.
for (const [key, waiter] of due) {
// ONE: the map no longer holds the waiter this entry is about. The nested pass released it and
// its replay re-parked, so the key names a NEW waiter that this store write never judged.
// Releasing by key would replay that pane a second time off a single write.
if (waitersByPane.get(key) !== waiter) {
continue
}
// TWO: the same waiter, re-judged against the same frame. `parkUntilHostMirrorHandleLands`
// re-parks a still-parked pane by MUTATING this object — `worktreeId` moves with `run` when
// adopting an orphaned terminal re-keys the rows — so identity survives it and the verdict
// taken above can be about a workspace the waiter is no longer filed under. Releasing on that
// is retraction evidence about the wrong workspace, the defect the `existing.worktreeId`
// assignment exists to prevent. Re-judging costs nothing: a waiter that is no longer due stays
// parked, bounded by its own deadline and judged again on the next write.
// The live store and not `state`, and NO TEST CAN TELL THE DIFFERENCE — deliberately. The two
// agree on every sequence the sweep can produce: a replay's only write is `createTab`, which
// appends a freshly minted tab id, so it can neither make an absent tab id present nor touch
// `ptyIdsByTabId`. They are kept apart anyway because if they ever did diverge `state` is the
// staler one, and its error is to RELEASE a pane whose row has come back — the direction this
// module exists to refuse. Holding on possibly-stale evidence costs a frame; acting on it is
// #19735. Do not "simplify" this to `state` on the grounds that nothing fails.
if (!waiterIsReleased(waiter, useAppStore.getState())) {
continue
}
releaseWaiter(key)
}
}
function startStoreSubscription(): void {
if (unsubscribeStore) {
return
}
let previous: HandleGapStoreState = useAppStore.getState()
unsubscribeStore = useAppStore.subscribe((state) => {
// Why: only these two slices can release a waiter; title, status, and
// usage ticks must not rescan every parked pane.
if (
state.ptyIdsByTabId === previous.ptyIdsByTabId &&
state.tabsByWorktree === previous.tabsByWorktree
) {
return
}
previous = state
retireVerdictsWithLandedHandles(state)
releaseDueWaiters(state)
stopStoreSubscriptionIfIdle()
})
}
/**
* Parks `run` until the pane's handle lands, its row is retracted, or the
* deadline expires. Re-parking an already-parked pane replaces `run` but keeps
* the original deadline, so a replay that re-parks cannot extend the wait.
*/
export function parkUntilHostMirrorHandleLands(
environmentId: string,
worktreeId: string,
tabId: string,
run: () => void
): void {
const key = paneWaitKey(environmentId, tabId)
const existing = waitersByPane.get(key)
if (existing) {
existing.run = run
// Why the worktree moves with `run`: adopting an orphaned terminal re-keys `tabsByWorktree`
// without re-keying the record, so a live wait left on the old worktree released on retraction
// evidence about a workspace it is no longer about. The park-time `paneBinding` deliberately
// does NOT move — that is the pane's identity, and this is only where its rows are filed.
existing.worktreeId = worktreeId
return
}
const generation = getRuntimeEnvironmentConnectionGeneration(environmentId)
const deadline = setTimeout(() => {
// Why the generation is re-read: a reconnect mid-park makes this wait's silence
// evidence about a connection that is gone. Recording it would let a wait armed
// milliseconds before the reconnect authorize a resume on the new one — the #19735
// fork with an extra step. Release without a verdict instead; the replay re-parks
// and the new connection gets its own full budget.
if (
waitersByPane.get(key)?.generation ===
getRuntimeEnvironmentConnectionGeneration(environmentId)
) {
recordExpiredWait(environmentId, key)
}
releaseWaiter(key)
}, HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
waitersByPane.set(key, {
worktreeId,
tabId,
generation,
paneBinding: paneBindingFor(tabId, environmentId),
deadline,
run
})
startStoreSubscription()
}
export function countParkedHostMirrorHandleGapPanesForTests(): number {
return waitersByPane.size
}
/**
* Drops the verdicts an environment's teardown makes unreachable.
*
* Only the verdicts. Parked waiters deliberately survive, matching
* `clearHostSessionMirrorHydration`: a re-pair or effect restart replaces the connection's
* evidence, it does not cancel the recovery this client still owes the pane. A waiter left here is
* bounded by its own deadline and replays the sweep exactly as it would have.
*/
export function clearHostMirrorHandleGapVerdictsForEnvironment(environmentId: string): void {
const prefix = `${environmentId}\0`
for (const key of expiredGenerationByPane.keys()) {
if (key.startsWith(prefix)) {
expiredGenerationByPane.delete(key)
}
}
// The landed-handle drain may have been the only thing holding the subscription open.
stopStoreSubscriptionIfIdle()
}
export function countHostMirrorHandleGapVerdictsForTests(): number {
return expiredGenerationByPane.size
}
export function resetHostMirrorHandleGapWaitsForTests(): void {
for (const waiter of waitersByPane.values()) {
clearTimeout(waiter.deadline)
}
waitersByPane.clear()
expiredGenerationByPane.clear()
unsubscribeStore?.()
unsubscribeStore = null
}
@@ -1,15 +1,34 @@
import type { useAppStore } from '@/store'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { hasHostSessionMirrorHydrated } from '@/runtime/host-session-mirror-hydration'
import { hasHostMirrorHandleWaitExpired } from './host-mirror-handle-gap-wait'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
type AppStoreState = ReturnType<typeof useAppStore.getState>
export type UnhydratedHostMirror = {
/** Null when no paired runtime claims the workspace, so nothing will ever answer for the pane. */
environmentId: string | null
export type UnhydratedHostMirror =
/** The host's tab rows have not arrived; mirror settlement replays the sweep. */
| {
kind: 'mirror'
/** Null when no paired runtime claims the workspace, so nothing will ever answer for the pane. */
environmentId: string | null
}
/** The rows arrived but this pane's PTY handle has not; a bounded per-pane wait replays. */
| { kind: 'handle'; environmentId: string; tabId: string }
/** The layout still binds a leaf of this tab to a PTY the environment minted. */
function tabHoldsEnvironmentPtyBinding(
state: AppStoreState,
tabId: string,
environmentId: string
): boolean {
const bindings = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
return Object.values(bindings).some(
(ptyId) => parseRemoteRuntimePtyId(ptyId)?.environmentId === environmentId
)
}
/**
@@ -19,7 +38,9 @@ export type UnhydratedHostMirror = {
* Why: a `web-terminal-*` tab exists only because a host published it, and its
* PTY handle arrives one relay round trip later. An empty local handle map is
* therefore "unverifiable", never "exited" — the incident's replacement
* `codex resume` forked a session the host still held.
* `codex resume` forked a session the host still held. Mirror hydration only
* says the rows landed, so a pane still bound to this environment's PTY with
* no handle yet gets its own bounded wait (#19735).
*/
export function findUnhydratedHostMirrorForPane(
record: SleepingAgentSessionRecord,
@@ -37,12 +58,34 @@ export function findUnhydratedHostMirrorForPane(
}
// Why: a published PTY handle for the tab is the mirror having spoken for it,
// whatever the individual leaf's fate.
//
// TAB-GRANULAR, and everything below this line is leaf-aware — the asymmetry is a known residual,
// not an oversight. For a single-leaf tab (every agent tab Orca creates) it is exact: the mirror
// builds `ptyIdsByTabId[tab]` out of the same map it writes to the layout's `ptyIdsByLeafId`
// (web-session-tabs-sync/terminal-build.ts), so a non-empty entry means this leaf is bound and
// live. For a SPLIT mirrored tab it is not. A leaf that has ever been bound keeps its binding
// across the gap — `retainPendingTerminalBindings` carries it — so the residual needs a leaf that
// was NEVER bound, i.e. a cold start or a re-pair with no layout to retain from. There, a sibling
// surface that reaches `ready` first publishes a handle for the tab while this leaf has none, the
// pane reads decidable, and the resume fires: #19735 narrowed to a split tab's first frame.
// It cannot be closed here, because such a leaf holds no binding and the binding is what names a
// pane in a handle-gap verdict. Closing it means keeping each surface's `pending-handle` status
// per leaf, which the host already publishes
// (main/runtime/runtime-mobile-session-projection.ts) and the client consumes but does not retain.
// Pinned as current behaviour by "resumes a pending leaf when a sibling leaf of the same tab
// holds the only handle" in host-mirror-handle-gap-resume.test.ts.
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
return null
}
const environmentId = getRuntimeEnvironmentIdForWorktree(state, record.worktreeId)
if (environmentId && hasHostSessionMirrorHydrated(environmentId, record.worktreeId)) {
return null
if (!environmentId || !hasHostSessionMirrorHydrated(environmentId, record.worktreeId)) {
return { kind: 'mirror', environmentId }
}
return { environmentId }
if (
tabHoldsEnvironmentPtyBinding(state, tabId, environmentId) &&
!hasHostMirrorHandleWaitExpired(environmentId, tabId)
) {
return { kind: 'handle', environmentId, tabId }
}
return null
}
@@ -141,4 +141,110 @@ describe('resume sleeping agent provider claims', () => {
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// Why a peer in another workspace is reachable at all: adopting an orphaned terminal re-keys
// `tabsByWorktree` onto the canonical worktree id and leaves the sleeping records that named the
// old one untouched (workspace-session-worktree-id.ts). A provider session id names one
// transcript, so the live pane owns it wherever it sits; resuming here forks the agent the user
// is watching. `done` is the cell that had no cover: a finished turn on a still-live pane.
// The same-workspace half of the same rule, pinned here so this file covers both cells whether or
// not #19736 (which fixes this one in `activeOrQueuedResumeClaimsProviderSession` too) has landed.
it('does not fork a provider session a live pane in this workspace already finished a turn on', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: { 'tab-peer': ['pty-peer'] },
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: { ...makeWorkingStatus(peerPaneKey, 'tab-peer', record), state: 'done' }
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// The load-bearing half of the pair: this is the only case that proves the live arm carries no
// workspace scope. The peer is `done` here too — a finished turn on a pane whose shell is still
// up — so "live" means the PTY, not the agent.
it('does not fork a provider session a live pane in another workspace already finished a turn on', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
// The record's own pane is gone, so nothing local can own its recovery.
tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: { 'tab-peer': ['pty-peer'] },
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: {
...makeWorkingStatus(peerPaneKey, 'tab-peer', record),
worktreeId: 'wt-2',
state: 'done'
}
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
const state = useAppStore.getState()
expect(state.tabsByWorktree['wt-1']).toHaveLength(0)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// The same peer without a live PTY is history, not a claim: the session must still come back.
it('still resumes when the other workspace peer finished and holds no live PTY', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: {},
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: {
...makeWorkingStatus(peerPaneKey, 'tab-peer', record),
worktreeId: 'wt-2',
state: 'done'
}
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
})
@@ -4,17 +4,23 @@ import {
type SleepingAgentSessionRecord
} from '../../../shared/agent-session-resume'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
getProviderSessionClaimKey,
isPassiveCompletedHibernationEvidence,
recordPaneIsOwnedByPreservedPane
recordPaneIsOwnedByPreservedPane,
stablePaneHasLivePty
} from './sleeping-agent-pane-ownership'
import {
launchSleepingAgentSession,
type ResumeSleepingAgentSessionsOptions
} from './sleeping-agent-session-launch'
import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness'
import {
findUnhydratedHostMirrorForPane,
type UnhydratedHostMirror
} from './host-mirrored-pane-liveness'
import { parkUntilHostMirrorHandleLands } from './host-mirror-handle-gap-wait'
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration'
@@ -96,12 +102,44 @@ function activeOrQueuedResumeClaimsProviderSession(
if (samePaneOwnsRecovery && entry.paneKey === record.paneKey) {
continue
}
const tabId = getAgentStatusTabId(entry)
const pane = parsePaneKey(entry.paneKey)
if (
entry.agentType !== record.agent ||
!agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession)
) {
continue
}
// Why this arm carries no workspace scope: a provider session id names one transcript, so a
// pane whose exact PTY is live right now already owns it wherever that pane happens to sit, and
// resuming forks the agent the user is watching. The scoped arm below still needs its scope —
// a status row with no live PTY is a claim about the past. The two ids do drift: adopting an
// orphaned terminal re-keys `tabsByWorktree` without re-keying the sleeping records that name
// the old id (workspace-session-worktree-id.ts), and a completed turn on a live pane is exactly
// where the drift stops being caught.
// What this trades, stated because it reads as a regression: `entry.state` is ignored, so a
// FINISHED agent whose shell is still up releases its record and will not auto-resume. That is
// the intended side of the trade, not an oversight. A live PTY is positive evidence the host
// holds the transcript, and a bare `done` row cannot be told apart from a REPL idling at its
// prompt with the process still attached. Nothing is killed: the pane, its shell and the
// transcript survive, the record was only a queued respawn, and the user can resume by hand.
// Forking the transcript is not recoverable; declining to auto-resume is.
if (
pane &&
tabId === pane.tabId &&
stablePaneHasLivePty(
pane.tabId,
pane.leafId,
state.ptyIdsByTabId,
state.terminalLayoutsByTabId[pane.tabId]
)
) {
return true
}
if (
worktreeTabIds.has(getAgentStatusTabId(entry) ?? '') &&
entry.worktreeId === record.worktreeId &&
entry.agentType === record.agent &&
entry.state !== 'done' &&
agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession)
worktreeTabIds.has(tabId ?? '') &&
entry.worktreeId === record.worktreeId
) {
return true
}
@@ -148,27 +186,37 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord):
)
}
function parkWorktreeResumeSweepUntilHostMirrorHydrates(
function replayParkedWorktreeResumeSweep(
worktreeId: string,
environmentId: string | null,
options: ResumeSleepingAgentSessionsOptions | undefined
): void {
if (!environmentId) {
// Why: the mirror can settle long after the user moved on, so a replayed
// resume must not steal the surface they are looking at now.
const isActive = useAppStore.getState().activeWorktreeId === worktreeId
// Why `skipClaimKeys` is dropped: it is a park-time snapshot of in-place
// wakes, and a latch that has since failed must stay resumable here.
resumeSleepingAgentSessionsForWorktree(worktreeId, {
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
...(isActive ? {} : { suppressNavigation: true })
})
}
function parkWorktreeResumeSweepUntilHostMirrorAnswers(
worktreeId: string,
mirror: UnhydratedHostMirror,
options: ResumeSleepingAgentSessionsOptions | undefined
): void {
const replay = (): void => replayParkedWorktreeResumeSweep(worktreeId, options)
if (mirror.kind === 'handle') {
parkUntilHostMirrorHandleLands(mirror.environmentId, worktreeId, mirror.tabId, replay)
return
}
if (!mirror.environmentId) {
// No paired runtime owns the workspace, so no verdict is coming; the next
// activation re-runs this sweep once one does.
return
}
parkUntilHostSessionMirrorHydrates(environmentId, worktreeId, () => {
// Why: the mirror can settle long after the user moved on, so a replayed
// resume must not steal the surface they are looking at now.
const isActive = useAppStore.getState().activeWorktreeId === worktreeId
// Why `skipClaimKeys` is dropped: it is a park-time snapshot of in-place
// wakes, and a latch that has since failed must stay resumable here.
resumeSleepingAgentSessionsForWorktree(worktreeId, {
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
...(isActive ? {} : { suppressNavigation: true })
})
})
parkUntilHostSessionMirrorHydrates(mirror.environmentId, worktreeId, replay)
}
export function resumeSleepingAgentSessionsForWorktree(
@@ -219,11 +267,7 @@ export function resumeSleepingAgentSessionsForWorktree(
// Why: pane ownership is undecidable until the mirror answers, and every
// branch below — launch and clear alike — trusts that verdict. Take no
// action on the record; the replay re-runs this pass with real evidence.
parkWorktreeResumeSweepUntilHostMirrorHydrates(
worktreeId,
unhydratedMirror.environmentId,
options
)
parkWorktreeResumeSweepUntilHostMirrorAnswers(worktreeId, unhydratedMirror, options)
continue
}
const isPaneOwned = recordPaneIsOwnedByPreservedPane(record, currentState)
@@ -94,7 +94,7 @@ function hasRestorableStablePanePty(
// the pane that reconnects on activation. Liveness comes from the runtime
// live-PTY map (ptyIdsByTabId), not the layout's ptyIdsByLeafId snapshot, which
// persists stale across sleep/restart.
function stablePaneHasLivePty(
export function stablePaneHasLivePty(
tabId: string,
leafId: string,
ptyIdsByTabId: Record<string, string[]>,
@@ -0,0 +1,117 @@
/**
* The resume half of the terminal-state floor.
*
* `workspace-terminal-host-authority.ts` says an SSH target whose sync terminated in
* `offline`/`error` without ever hydrating answers `none`, so this client may act. The seeding
* consumer is covered end to end (worktree-agent-activation-seam.test.ts); the sleeping-agent
* consumer (resume-sleeping-agent-session.ts) was only covered at the predicate. Without this,
* a failed target's agents stay unresumable for the rest of the app session and nothing fails.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { useAppStore } from '@/store'
import { makeWorktree } from '@/store/slices/store-test-helpers'
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
const initialAppStoreState = useAppStore.getState()
const TARGET_ID = 'ssh-target-1'
const WORKTREE_ID = 'repoSsh::/srv/proj/feature'
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
})
function seedFailedSshTarget(phase?: 'offline' | 'error' | 'pulling'): void {
const tab: TerminalTab = {
id: 'tab-1',
ptyId: null,
worktreeId: WORKTREE_ID,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const record: SleepingAgentSessionRecord = {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: WORKTREE_ID,
agent: 'pi',
providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath: '/tmp/pi-1.jsonl' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
useAppStore.setState({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
repos: [
{
id: 'repoSsh',
path: '/srv/proj',
displayName: 'repoSsh',
badgeColor: '#000',
addedAt: 0,
connectionId: TARGET_ID
}
] as never,
worktreesByRepo: {
repoSsh: [
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
makeWorktree({
id: WORKTREE_ID,
repoId: 'repoSsh',
path: '/srv/proj/feature',
hostId: `ssh:${TARGET_ID}`
} as never)
]
},
remoteWorkspaceHydratedTargetIds: new Set<string>(),
remoteWorkspaceSyncStatusByTargetId:
phase === undefined ? {} : { [TARGET_ID]: { phase, direction: 'pull' as const } },
tabsByWorktree: { [WORKTREE_ID]: [tab] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
})
}
describe('sleeping-agent resume on a failed SSH target', () => {
it.each(['offline', 'error'] as const)(
'resumes a sleeping agent once a sync terminates in %s without ever hydrating',
(phase) => {
seedFailedSshTarget(phase)
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'none'
)
// The gate this exists for: a target that failed must not stay unresumable for the session.
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
}
)
it('still declines to resume while the host has not answered', () => {
// Control: an in-flight sync is `unverifiable`, and resuming there forks a session the host
// may still be running. The floor must not widen into "resume whenever we are unsure".
seedFailedSshTarget('pulling')
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'unverifiable'
)
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeDefined()
})
it('still declines to resume when no sync status exists at all', () => {
seedFailedSshTarget(undefined)
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'unverifiable'
)
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
})
})
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
markHostSessionMirrorHydrated,
parkUntilHostSessionMirrorHydrates,
resetHostSessionMirrorHydrationForTests
} from './host-session-mirror-hydration'
// The same fan-out hazard as host-mirror-handle-gap-drain.test.ts, one module up: settling an
// environment drains every worktree parked on it in one loop, from inside the frame apply. The
// waiters are strangers to each other and to that apply, so one replay must not be able to reach
// either of them.
const ENVIRONMENT_ID = 'env-hydration-drain'
describe('host session mirror hydration drain', () => {
afterEach(() => {
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
it('settles the remaining parked worktrees when one replay throws', () => {
const secondReplay = vi.fn()
parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, 'repo::first', () => {
throw new Error('replay blew up')
})
parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, 'repo::second', secondReplay)
expect(() => markHostSessionMirrorHydrated(ENVIRONMENT_ID)).not.toThrow()
expect(secondReplay).toHaveBeenCalledTimes(1)
})
})
@@ -53,7 +53,14 @@ function drainParkedWaiters(matches: (waiter: ParkedMirrorWaiter) => boolean): v
const waiter = parkedWaitersByWorktree.get(key)
if (waiter) {
parkedWaitersByWorktree.delete(key)
waiter.run()
try {
waiter.run()
} catch (error) {
// Why: one settle drains every waiter the environment holds, and they are strangers to each
// other and to the frame apply that called it. An unguarded throw strands every waiter
// queued behind this one and surfaces in the caller applying the frame.
console.warn('[host-session-mirror-hydration] parked replay failed:', error)
}
}
}
}
@@ -38,6 +38,7 @@ import {
clearWebSessionTerminalPlacementsForEnvironment
} from '../web-session-terminal-placement'
import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration'
import { clearHostMirrorHandleGapVerdictsForEnvironment } from '@/lib/host-mirror-handle-gap-wait'
import { clearHostSessionTabIdMappings } from './tracking-mappings'
import {
sessionTabsFreshnessKey,
@@ -218,6 +219,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId)
clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId)
clearHostSessionMirrorHydration(trimmedEnvironmentId)
clearHostMirrorHandleGapVerdictsForEnvironment(trimmedEnvironmentId)
clearAllWebRuntimeWakeTerminalRespawn()
}