From 2da662424ba74acdde5d4e37e1131dc655571f4d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 17:47:58 -0700 Subject: [PATCH] fix(runtime): an outage is not a handle-gap verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/host-mirror-handle-gap-resume.test.ts | 51 +++++++++++++++++++ .../src/lib/host-mirror-handle-gap-wait.ts | 34 ++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts index 3d5e2625944..5d77a679caf 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts @@ -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) diff --git a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts index 9582dcc9d6b..278d5f5fddc 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts @@ -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) }