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.
This commit is contained in:
Neil
2026-09-15 22:06:08 -07:00
parent 1824527a71
commit 9f52dbbcb9
6 changed files with 119 additions and 14 deletions
@@ -36,6 +36,7 @@ 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'
@@ -87,6 +88,40 @@ function seedMirroredWorkspace(worktree: ReturnType<typeof makeCreatedAgentWorkt
useAppStore.setState(state as AppState)
}
/**
* Rebinds the published tab as a SPLIT whose sibling leaf is `ready` and whose record-bearing leaf
* is still `pending-handle`.
*
* Why this shape and not "the tab published a handle nobody's leaf is bound to": the mirror builds
* `ptyIdsByTabId[tab]` out of the very map it writes to `terminalLayoutsByTabId[tab].ptyIdsByLeafId`
* (web-session-tabs-sync/terminal-build.ts), so those two can never disagree about which PTY ids
* exist. The only producible way for a tab to hold handles while THIS pane holds none is a split
* whose other surface went `ready` first — a per-surface state the host projects
* (runtime-mobile-session-projection.ts) and the client does not keep.
*/
function seedPendingLeafBesideReadySibling(): 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({
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.
[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,
// The record's leaf is absent: pending-handle with no prior binding to retain.
ptyIdsByLeafId: { [SIBLING_LEAF_ID]: 'remote:env-handle-gap@@term_sibling' }
} as never
}
} 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()
@@ -184,14 +219,31 @@ describe('resume across the mirror handle gap', () => {
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
it('still resumes once the host has published the row without any live handle', () => {
// 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)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@other'] } })
// 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
@@ -215,14 +267,29 @@ describe('resume across the mirror handle gap', () => {
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
it('releases when a handle lands for the tab and resumes if it belongs to another pane', () => {
// 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. In a SPLIT mirrored
// tab a sibling surface reaching `ready` therefore ends the wait for a surface that is still
// `pending-handle`, and the sweep resumes it — #19735's own shape, narrowed to split tabs.
//
// It is not closable inside this module: during the gap the pane's own 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.
// The client has to stop discarding each surface's `pending-handle` status
// (runtime-mobile-session-projection.ts publishes it; terminal-build.ts keeps only `ready`
// leaves' bindings). Tracked separately; this case exists so the residual cannot be mistaken for
// a covered one.
it('resumes a pending leaf once a sibling leaf of the same tab publishes its handle', () => {
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@@other'] } })
seedPendingLeafBesideReadySibling()
useAppStore.setState({
ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_sibling'] }
})
const after = useAppStore.getState()
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
@@ -17,10 +17,11 @@ import {
// - 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. That mutation
// passes all 272 tests across the 33 other handle-gap and session-tabs suites. A leaked
// - 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
// no test notices.
// 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
@@ -118,7 +118,9 @@ describe('handle-gap verdict map, all rules on one tree', () => {
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 drained: env-a's expiry retired env-c's superseded row, though env-c never expired again.
// 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:
@@ -191,9 +193,10 @@ describe('handle-gap verdict map, all rules on one tree', () => {
})
it('never evicts a live pane verdict, whichever environment sweeps', () => {
// Three environments on purpose: with two at one generation the candidate rules are
// indistinguishable and the naive "judge everything against the recording environment"
// mutation survives. env-c is the discriminator — its verdict is live.
// 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)
}
@@ -213,13 +216,24 @@ describe('handle-gap verdict map, all rules on one tree', () => {
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('returns to baseline under churn across all three drains', () => {
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)
setLiveTabs([`tab-${round}`])
// Bind each round's pane to the environment that is recording it. Falling back to env-a for
// every round would store `paneBinding: ''` on two rounds in three, which is the match value
// the read-time check refuses — two thirds of the churn would then be synthetic.
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 —
// each environment's own generation bump retires its predecessors, and the two environments
// not recording keep exactly their latest row.
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(3)
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
clearHostMirrorHandleGapVerdictsForEnvironment(environmentId)
}
@@ -58,6 +58,19 @@ 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 sibling surface reaching `ready` first ends the
// wait for one still `pending-handle`, and the resume fires — #19735 narrowed to split tabs.
// It cannot be closed here, because during the gap the pane's own 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, which the host already publishes
// (main/runtime/runtime-mobile-session-projection.ts) and the client drops on apply.
// Pinned as current behaviour by "resumes a pending leaf once a sibling leaf of the same tab
// publishes its handle" in host-mirror-handle-gap-resume.test.ts.
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
return null
}
@@ -177,7 +177,10 @@ describe('resume sleeping agent provider claims', () => {
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('does not fork a provider session a live pane in another workspace is running', () => {
// 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)
@@ -117,6 +117,13 @@ function activeOrQueuedResumeClaimsProviderSession(
// 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 &&