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 2101451b32e..baefdbe624e 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 @@ -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) 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 e2d3a58367d..076ba8fefa1 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts @@ -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 { 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) }