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.
This commit is contained in:
Neil
2026-09-10 17:47:58 -07:00
parent 9a81146e13
commit 2da662424b
2 changed files with 84 additions and 1 deletions
@@ -140,6 +140,22 @@ 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 {
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map(useAppStore.getState().runtimeStatusByEnvironmentId).set(
environmentId,
{ status: null } as never
)
} as never)
}
function clearRuntimeEnvironmentStatusEntryForTests(environmentId: string): void {
const next = new Map(useAppStore.getState().runtimeStatusByEnvironmentId)
next.delete(environmentId)
useAppStore.setState({ runtimeStatusByEnvironmentId: next } as never)
}
describe('resume across the mirror handle gap', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -332,6 +348,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)
@@ -1,6 +1,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 {
isDisconnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from '@/runtime/runtime-host-connection-state'
/**
* Per-pane park for the frame between a host's tab rows and its PTY handles.
@@ -54,6 +58,27 @@ export function hasHostMirrorHandleWaitExpired(environmentId: string, tabId: str
)
}
/**
* 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)
// Why: a verdict from a previous connection is dead weight; drop it so the map
@@ -150,9 +175,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)
}