mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(runtime): refuse to silently wake a deliberately slept pane
`activateMobileSessionTab` decided whether to materialize a pane from
`publicTab.status !== 'ready'` alone. A pane the user put to sleep publishes
`pending-handle` indefinitely, so it was indistinguishable there from a pane
whose PTY was lost and is awaiting reconnect — and activating it respawned it
with a freshly re-resolved agent launch the user never asked for.
Gate materialization on the durable sleeping record's `origin`. Only a
worktree-owned capture ('worktree-sleep', written by workspace sleep and by
completed-agent hibernation) records a deliberate takedown; 'live' and 'quit'
captures describe a pane that was still running, so reconnect-driven
materialization (#11542) is unchanged for them.
This commit is contained in:
@@ -79,6 +79,7 @@ import {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
|
||||
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES,
|
||||
@@ -29145,6 +29146,157 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deliberately parked pane activation (STA-3465)', () => {
|
||||
function makeParkedSessionStore(
|
||||
origin: SleepingAgentSessionRecord['origin'] | undefined,
|
||||
overrides: Partial<SleepingAgentSessionRecord> = {}
|
||||
) {
|
||||
return makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[`host-tab:${HEADLESS_LEAF_ID}`]: {
|
||||
paneKey: `host-tab:${HEADLESS_LEAF_ID}`,
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'provider-session-1' },
|
||||
prompt: 'do the thing',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
...(origin ? { origin } : {}),
|
||||
...overrides
|
||||
} as SleepingAgentSessionRecord
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function makeParkedRuntime(runtimeStore: unknown): {
|
||||
runtime: OrcaRuntimeService
|
||||
spawn: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' })
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
return { runtime, spawn }
|
||||
}
|
||||
|
||||
it('refuses to respawn a deliberately slept pane on a reconnect-driven activate', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ notifyClients: false, navigation: 'caller' }
|
||||
)
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(activated.tabs[0]).toMatchObject({
|
||||
type: 'terminal',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
// Negative safety: refusing to wake must not retire the surface either.
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject(
|
||||
{ parentTabId: 'host-tab', status: 'pending-handle' }
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to respawn a deliberately slept pane on a headless host activate', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null })
|
||||
})
|
||||
|
||||
// Why: #11542's reconnect fix depends on activate materializing a genuinely
|
||||
// awaiting pane. These three prove the park guard did not break it.
|
||||
it('still materializes a pane awaiting reconnect with no sleeping record', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ notifyClients: false, navigation: 'caller' }
|
||||
)
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
sessionId: 'persisted-pty'
|
||||
})
|
||||
)
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
it('still materializes a pane whose record was captured while it was live', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('live')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ notifyClients: false, navigation: 'caller' }
|
||||
)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
it('still materializes a pane whose record was captured at app quit', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('quit')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ notifyClients: false, navigation: 'caller' }
|
||||
)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
it('ignores a park record that belongs to a different worktree', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {
|
||||
worktreeId: 'other-repo::/other'
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ notifyClients: false, navigation: 'caller' }
|
||||
)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
})
|
||||
|
||||
it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
|
||||
@@ -7321,6 +7321,7 @@ export class OrcaRuntimeService {
|
||||
const shouldMaterializePendingTerminal =
|
||||
publicTab?.type === 'terminal' &&
|
||||
publicTab.status !== 'ready' &&
|
||||
!this.isDeliberatelyParkedPane(worktreeId, tab) &&
|
||||
(!targetsHost ||
|
||||
!this.notifier?.focusTerminal ||
|
||||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
|
||||
@@ -7474,6 +7475,28 @@ export class OrcaRuntimeService {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether persistence proves this pane's PTY was deliberately taken down and parked
|
||||
* (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect.
|
||||
* Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it
|
||||
* indefinitely — and respawning a parked pane re-launches its agent behind the user.
|
||||
*/
|
||||
private isDeliberatelyParkedPane(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
const record =
|
||||
this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[
|
||||
makePaneKey(tab.parentTabId, tab.leafId)
|
||||
]
|
||||
// Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect
|
||||
// must still mint its replacement PTY (#11542). Only a worktree-owned capture records
|
||||
// a deliberate takedown the user did not ask to undo.
|
||||
return (
|
||||
record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
private shouldMaterializeHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
|
||||
Reference in New Issue
Block a user