fix(runtime): an outage is not a handle-gap verdict

The per-pane handle-gap wait releases at a 15s deadline and records that
expiry as a verdict, which authorises the sleeping-agent resume. The
connection generation was the only thing voiding that verdict, and a plain
disconnect never advances it — runtime-status.ts advances on the reconnect,
under a new runtime id. So a network drop mid-turn expired the wait with a
generation that still matched, and the replay forked a second `--resume`
onto the transcript the host was still writing: #19735 through the
disconnect door.

Suppress the verdict while the client positively knows it is out of contact,
reusing the shared runtime-host connection derivation. The waiter still
releases and re-parks, so contact returning gets a full fresh budget and the
pane is still decided on real silence.

Not redundant with the landed-handle drain that follows this commit, nor with
the read-time pane identity from adv2-skew (cdafc90d8f). Mutation on the
composed tree gives three disjoint kills: dropping this guard fails only "does
not turn an outage into a verdict"; dropping the landed-handle drain fails only
the two landed-handle cases; forcing this guard always-true fails 16 across every
suite. Three guards, three holes.
This commit is contained in:
Neil
2026-09-18 23:47:37 -07:00
parent 7063c2cbdd
commit aef5ced43c
2 changed files with 86 additions and 1 deletions
@@ -12,6 +12,7 @@ import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import type { RuntimeEnvironmentStatus } from '@/store/slices/runtime-status-types'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
@@ -190,6 +191,23 @@ function seedActiveSleepingRecord(worktreeId: string): string {
return seedActiveSleepingRecordFor(worktreeId, WEB_TAB_ID, LEAF_ID, 'handle-gap-session')
}
/** A recorded status entry whose runtime answered nothing: the shape a dropped link leaves behind. */
function setRuntimeEnvironmentDisconnectedForTests(environmentId: string): void {
const disconnected: RuntimeEnvironmentStatus = { status: null, checkedAt: 0 }
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map(useAppStore.getState().runtimeStatusByEnvironmentId).set(
environmentId,
disconnected
)
})
}
function clearRuntimeEnvironmentStatusEntryForTests(environmentId: string): void {
const next = new Map(useAppStore.getState().runtimeStatusByEnvironmentId)
next.delete(environmentId)
useAppStore.setState({ runtimeStatusByEnvironmentId: next })
}
describe('resume across the mirror handle gap', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -412,6 +430,41 @@ describe('resume across the mirror handle gap', () => {
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
// The journey: the network drops mid-turn on a paired runtime. Nothing is unpaired and no
// reconnect has happened, so the connection generation has not moved — runtime-status.ts
// advances it on the *reconnect*, under a new runtime id. The deadline therefore fires with a
// generation that still matches, and its silence is about the outage, not about the host. A
// verdict recorded there resumes the agent the host is still running (#19735 through the
// disconnect door, docs/reference/ssh-execution-boundary.md).
it('does not turn an outage into a verdict when the environment dropped mid-park', () => {
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)
setRuntimeEnvironmentDisconnectedForTests(RUNTIME_ENV_ID)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const during = useAppStore.getState()
expect(during.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(during.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
expect((during.tabsByWorktree[worktree.id] ?? []).map((tab) => tab.id)).toEqual([WEB_TAB_ID])
// Held, not abandoned: something is still armed to decide once contact returns.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
// Contact returns and the host still publishes no handle for the pane. That silence IS
// evidence, so the next full budget decides — a hold that outlives the outage would be the
// latch-that-never-releases defect this module exists to avoid.
clearRuntimeEnvironmentStatusEntryForTests(RUNTIME_ENV_ID)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const after = useAppStore.getState()
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
it('releases only the pane whose handle landed when two panes share the environment', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
@@ -2,6 +2,10 @@ 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'
import {
isDisconnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from '@/runtime/runtime-host-connection-state'
/**
* Per-pane park for the frame between a host's tab rows and its PTY handles.
@@ -162,6 +166,27 @@ function liveTabIds(): Set<string> {
return tabIds
}
/**
* True only when the client positively knows it is out of contact — the link dropped, or its
* replacement is still being established.
*
* Why not `isConnectedRuntimeHostState`: that reads a host nobody has probed yet as not
* connected, and a never-probed host is not the outage this guards. Narrowing to the two states
* an outage actually produces keeps the guard to the case where silence provably means "we could
* not ask" rather than "the host had nothing to say".
*
* Why this and not the connection generation: a plain disconnect leaves the generation where it
* was — runtime-status.ts advances it on the *reconnect*, under a new runtime id — so a wait that
* expires mid-outage is indistinguishable, to the generation guard, from one that expired on a
* healthy connection.
*/
function environmentContactIsLost(environmentId: string): boolean {
const connectionState = runtimeHostConnectionStateForEntry(
useAppStore.getState().runtimeStatusByEnvironmentId.get(environmentId)
)
return isDisconnectedRuntimeHostState(connectionState) || connectionState === 'reconnecting'
}
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
@@ -348,9 +373,16 @@ export function parkUntilHostMirrorHandleLands(
// 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.
//
// Why contact is checked too: an environment that dropped mid-park publishes nothing,
// so the deadline measures the outage rather than the host. Loss of contact is never
// evidence about a process (docs/reference/ssh-execution-boundary.md), and a verdict
// recorded here authorizes the resume that forks the agent the host is still running.
// The generation cannot stand in for it — a plain disconnect never advances it.
if (
!environmentContactIsLost(environmentId) &&
waitersByPane.get(key)?.generation ===
getRuntimeEnvironmentConnectionGeneration(environmentId)
getRuntimeEnvironmentConnectionGeneration(environmentId)
) {
recordExpiredWait(environmentId, key)
}