mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user