fix(runtime): refuse to silently wake a deliberately slept pane (STA-3465) (#12672)
`activateMobileSessionTab` gated only on `publicTab.status !== 'ready'`. A deliberately slept pane publishes as `pending-handle` indefinitely — indistinguishable at that call site from a pane awaiting reconnect — so the reconnect probe added by #11542 respawned it with a re-resolved agent launch, waking something the user had deliberately put to sleep. The first attempt refused activation for any pane with a `worktree-sleep` record, applied to every path. Independent review found that broke the documented wake gesture: opening the tab IS how those panes are meant to cold-restore (`wake-sleeping-agents-in-background.ts`: "Those panes cold-restore --resume when their own tab is opened"). A mobile tap sends the byte-identical call the reproduction test used, and in three of four topologies no wake clears the record first — so the tap became a permanent no-op with no feedback. This carries intent explicitly instead of inferring it. A new shared `TabActivationIntent` ('user' | 'automatic') rides the existing ActivateTab schema as an optional additive field; `isAutomaticTabActivation` returns true only for an explicit 'automatic', so an absent value is permissive BY CONSTRUCTION in one place — an older client that does not send it keeps today's behavior rather than silently losing its wake gesture. The field is required on the mobile helper's params, so no call site can be added without declaring who asked. Every user path (mobile tab switches, paired tab clicks, shortcuts, palette, the pane's own open) is labelled 'user'. The only automatic sender in the codebase is `waitForResubscribeHostSessionHandle`, the #11542 reconnect probe. Verified per topology: user activation materializes a parked pane under headless serve, a paired runtime client, a completed agent with restoreOnTabOpenOnly, and a running agent whose wake cleared the record. The automatic probe is refused without retiring the surface, and #11542's reconnect tests stay green. Also fixes a test fixture that made a real bug untestable: the store stub ignored the host id, so mutating the partition lookup to 'local' left the suite green. Correcting it exposed three existing SSH reattach tests that had been relying on that looseness — their workspace session sat in the local partition while their repo was SSH-hosted, a store production would never read. Production was always right; the tests described an impossible world. Fixes STA-3465.
This commit is contained in:
parent
9accd97bd9
commit
a766ee4bcd
|
|
@ -2882,7 +2882,8 @@ export default function SessionScreen() {
|
|||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
|
@ -2922,7 +2923,8 @@ export default function SessionScreen() {
|
|||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
|
|
@ -2946,7 +2948,8 @@ export default function SessionScreen() {
|
|||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
|
|
@ -4197,7 +4200,10 @@ export default function SessionScreen() {
|
|||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
// Why: this only ever runs for the tab the user is looking at, so it is the
|
||||
// tail of their tap — the gesture that materializes a parked pane.
|
||||
intent: 'user'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ describe('mobile session tab activation', () => {
|
|||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
notifyClients: false as const,
|
||||
navigation: 'caller' as const
|
||||
navigation: 'caller' as const,
|
||||
intent: 'user' as const
|
||||
}
|
||||
|
||||
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
|
@ -15,6 +16,8 @@ type MobileSessionTabActivationParams = {
|
|||
leafId?: string
|
||||
notifyClients: false
|
||||
navigation: 'caller'
|
||||
/** Required so each call site declares whether a user asked for this. */
|
||||
intent: TabActivationIntent
|
||||
}
|
||||
|
||||
async function retryIdempotentActivationAfterCutover(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1413,9 +1414,14 @@ function makeHeadlessTerminalLayout(
|
|||
}
|
||||
}
|
||||
|
||||
function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionState): {
|
||||
function makeRuntimeStoreWithWorkspaceSession(
|
||||
initialSession: WorkspaceSessionState,
|
||||
// Why: sessions are partitioned by execution host, so the stub must answer for
|
||||
// one partition only — a loose stub lets a hardcoded host id pass unnoticed.
|
||||
ownerHostId = 'local'
|
||||
): {
|
||||
runtimeStore: typeof store & {
|
||||
getWorkspaceSession: () => WorkspaceSessionState
|
||||
getWorkspaceSession: (hostId?: string) => WorkspaceSessionState
|
||||
setWorkspaceSession: ReturnType<typeof vi.fn>
|
||||
persistPtyBinding: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
|
@ -1428,7 +1434,8 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt
|
|||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSession: (hostId?: string) =>
|
||||
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: vi.fn(setSession),
|
||||
persistPtyBinding: vi.fn(
|
||||
(args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => {
|
||||
|
|
@ -29145,6 +29152,293 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('deliberately parked pane activation (STA-3465)', () => {
|
||||
function makeParkedSessionStore(
|
||||
origin: SleepingAgentSessionRecord['origin'] | undefined,
|
||||
overrides: Partial<SleepingAgentSessionRecord> = {},
|
||||
ownerHostId = 'local'
|
||||
) {
|
||||
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
|
||||
}
|
||||
}),
|
||||
ownerHostId
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
function setParkedRuntimeNotifier(
|
||||
runtime: OrcaRuntimeService,
|
||||
resumeSleepingAgents: (worktreeId: string) => void
|
||||
): void {
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
resumeSleepingAgents,
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.markGraphReady(TEST_WINDOW_ID)
|
||||
}
|
||||
|
||||
const userActivate = (
|
||||
runtime: OrcaRuntimeService,
|
||||
leafId?: string
|
||||
): Promise<RuntimeMobileSessionTabsResult> =>
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, {
|
||||
notifyClients: false,
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
})
|
||||
|
||||
const automaticActivate = (
|
||||
runtime: OrcaRuntimeService,
|
||||
leafId?: string
|
||||
): Promise<RuntimeMobileSessionTabsResult> =>
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, {
|
||||
notifyClients: false,
|
||||
navigation: 'caller',
|
||||
intent: 'automatic'
|
||||
})
|
||||
|
||||
it('refuses an automatic reconnect probe for a deliberately slept pane', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await automaticActivate(runtime)
|
||||
|
||||
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 an automatic probe that carries the leafId the probe sends', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await automaticActivate(runtime, HEADLESS_LEAF_ID)
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null })
|
||||
})
|
||||
|
||||
// Why: opening the tab is the documented wake gesture for a slept pane
|
||||
// (#11598). These four cover every topology, because three of them never
|
||||
// clear the record — the pane's own activation is the only thing that wakes it.
|
||||
it('materializes a slept pane for a user tap under headless serve, which never wakes', async () => {
|
||||
const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep')
|
||||
const resumeSleepingAgents = vi.fn()
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
setParkedRuntimeNotifier(runtime, resumeSleepingAgents)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
|
||||
|
||||
const worktreeActivation = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
|
||||
expect(worktreeActivation.sleepingAgentWake).toBe('unsupported-headless')
|
||||
expect(resumeSleepingAgents).not.toHaveBeenCalled()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`]?.origin
|
||||
).toBe('worktree-sleep')
|
||||
|
||||
const activated = await userActivate(runtime, HEADLESS_LEAF_ID)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
it('materializes a slept pane for a paired desktop client tab click, which asks for no wake', async () => {
|
||||
const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep')
|
||||
const resumeSleepingAgents = vi.fn()
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
setParkedRuntimeNotifier(runtime, resumeSleepingAgents)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
|
||||
|
||||
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'runtime'
|
||||
})
|
||||
|
||||
expect(resumeSleepingAgents).not.toHaveBeenCalled()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`]
|
||||
).toBeDefined()
|
||||
|
||||
const activated = await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// Why: manual sleep of a finished agent stamps restoreOnTabOpenOnly, which the
|
||||
// background wake skips and resume classifies pane-owned, so the record survives.
|
||||
it('materializes a slept pane whose completed-agent record is restore-on-tab-open-only', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {
|
||||
state: 'done',
|
||||
restoreOnTabOpenOnly: true
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
const activated = await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// Why: manual sleep of a running agent is the one topology whose wake relaunches
|
||||
// and clears the record, so the pane must materialize with the record gone too.
|
||||
it('materializes a slept running-agent pane after its wake cleared the record', async () => {
|
||||
const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep', {
|
||||
state: 'working'
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
const woken = structuredClone(getSession())
|
||||
delete woken.sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`]
|
||||
setSession(woken)
|
||||
|
||||
const activated = await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// Why: the field is additive, so a client that predates it sends nothing and
|
||||
// must keep its wake gesture rather than silently losing it.
|
||||
it('treats an absent intent as a user activation', 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).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// Why: #11542's reconnect fix depends on an automatic activate materializing a
|
||||
// genuinely awaiting pane. These four 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 automaticActivate(runtime)
|
||||
|
||||
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 automaticActivate(runtime)
|
||||
|
||||
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 automaticActivate(runtime)
|
||||
|
||||
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 automaticActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// Why: sleeping records live in the owning execution host's session partition,
|
||||
// so reading a fixed partition would miss the record on an SSH-host worktree.
|
||||
it('reads the park record from the worktree own execution-host partition', async () => {
|
||||
const sshRepo = { ...store.getRepos()[0]!, executionHostId: 'ssh:ssh-1' as const }
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {}, 'ssh:ssh-1')
|
||||
const { runtime, spawn } = makeParkedRuntime({
|
||||
...runtimeStore,
|
||||
getRepos: () => [sshRepo],
|
||||
getRepo: (id: string) => (id === TEST_REPO_ID ? sshRepo : undefined)
|
||||
})
|
||||
|
||||
const activated = await automaticActivate(runtime)
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null })
|
||||
})
|
||||
})
|
||||
|
||||
it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
|
|
@ -29167,7 +29461,8 @@ describe('OrcaRuntimeService', () => {
|
|||
[HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty'
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' }
|
||||
const remoteStore = {
|
||||
|
|
@ -29220,7 +29515,8 @@ describe('OrcaRuntimeService', () => {
|
|||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: stalePtyId })
|
||||
}
|
||||
})
|
||||
}),
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' }
|
||||
const remoteStore = {
|
||||
|
|
@ -29446,7 +29742,8 @@ describe('OrcaRuntimeService', () => {
|
|||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined })
|
||||
}
|
||||
})
|
||||
}),
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' }
|
||||
const remoteStore = {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,10 @@ import {
|
|||
navigationTargetsHost,
|
||||
type RuntimeNavigationTarget
|
||||
} from '../../shared/runtime-navigation'
|
||||
import {
|
||||
isAutomaticTabActivation,
|
||||
type TabActivationIntent
|
||||
} from '../../shared/tab-activation-intent'
|
||||
import type { SshConnectionState } from '../../shared/ssh-types'
|
||||
import { getPublicSshState } from './public-ssh-state'
|
||||
import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close'
|
||||
|
|
@ -7321,6 +7325,7 @@ export class OrcaRuntimeService {
|
|||
notifyClients?: boolean
|
||||
clientNavigationId?: string
|
||||
navigation?: RuntimeNavigationTarget
|
||||
intent?: TabActivationIntent
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionTabsResult> {
|
||||
const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all')
|
||||
|
|
@ -7362,6 +7367,10 @@ export class OrcaRuntimeService {
|
|||
const shouldMaterializePendingTerminal =
|
||||
publicTab?.type === 'terminal' &&
|
||||
publicTab.status !== 'ready' &&
|
||||
// Why: opening a tab is the documented wake gesture for a slept pane
|
||||
// (#11598), so only a background probe may be refused for one.
|
||||
(!isAutomaticTabActivation(opts.intent) ||
|
||||
!this.isDeliberatelyParkedPane(worktreeId, tab)) &&
|
||||
(!targetsHost ||
|
||||
!this.notifier?.focusTerminal ||
|
||||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
|
||||
|
|
@ -7515,6 +7524,29 @@ 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.
|
||||
* Only an automatic activation consults this; a user opening the tab is the wake gesture.
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
|||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
|
||||
import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation'
|
||||
import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent'
|
||||
import { OptionalBoolean } from '../schemas'
|
||||
|
||||
export const WorktreeTabSelector = z.object({
|
||||
|
|
@ -24,7 +25,10 @@ export const ActivateTab = WorktreeTabSelector.extend({
|
|||
.pipe(z.string().min(1, 'Missing tab id')),
|
||||
leafId: z.string().max(128).optional(),
|
||||
notifyClients: OptionalBoolean,
|
||||
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
|
||||
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
|
||||
// Why: absent means user intent, so clients that predate this field keep the
|
||||
// tab-open wake gesture. Only 'automatic' may be refused for a slept pane.
|
||||
intent: z.enum(TAB_ACTIVATION_INTENTS).optional()
|
||||
})
|
||||
|
||||
export const CloseTab = ActivateTab.extend({
|
||||
|
|
|
|||
|
|
@ -68,6 +68,58 @@ describe('session tab RPC methods', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Why: only this field separates a reconnect probe from a user opening the tab,
|
||||
// and the tab-open gesture is what wakes a deliberately slept pane (STA-3465).
|
||||
it('forwards an automatic activation intent to the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.activate', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
notifyClients: false,
|
||||
intent: 'automatic'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
'tab-1',
|
||||
undefined,
|
||||
expect.objectContaining({ intent: 'automatic' })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the field is additive, so a client that predates it sends nothing and
|
||||
// must keep the permissive default rather than losing its wake gesture.
|
||||
it('passes no intent for a client that omits the field', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.activate', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
notifyClients: false
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', undefined, {
|
||||
notifyClients: false,
|
||||
clientNavigationId: undefined,
|
||||
navigation: 'caller'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a reasonless close without invoking destructive runtime logic', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
|||
runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, {
|
||||
notifyClients: params.notifyClients !== false,
|
||||
clientNavigationId: pairedDeviceId,
|
||||
...(params.intent ? { intent: params.intent } : {}),
|
||||
navigation: resolveRuntimeNavigationTarget({
|
||||
navigation: params.navigation,
|
||||
notifyClients: params.notifyClients,
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () =
|
|||
let hostActivated = false
|
||||
let activateCallsAfterOutage = 0
|
||||
let listCallsAfterOutage = 0
|
||||
const activateIntentsAfterOutage: unknown[] = []
|
||||
const hostSnapshot = () => ({
|
||||
ok: true,
|
||||
result: {
|
||||
|
|
@ -386,18 +387,21 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () =
|
|||
]
|
||||
}
|
||||
})
|
||||
runtimeCall.mockImplementation(async (request: { method: string }) => {
|
||||
if (request.method === 'session.tabs.list') {
|
||||
listCallsAfterOutage += 1
|
||||
return hostSnapshot()
|
||||
runtimeCall.mockImplementation(
|
||||
async (request: { method: string; params?: { intent?: unknown } }) => {
|
||||
if (request.method === 'session.tabs.list') {
|
||||
listCallsAfterOutage += 1
|
||||
return hostSnapshot()
|
||||
}
|
||||
if (request.method === 'session.tabs.activate') {
|
||||
activateCallsAfterOutage += 1
|
||||
activateIntentsAfterOutage.push(request.params?.intent)
|
||||
hostActivated = true
|
||||
return hostSnapshot()
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
if (request.method === 'session.tabs.activate') {
|
||||
activateCallsAfterOutage += 1
|
||||
hostActivated = true
|
||||
return hostSnapshot()
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
})
|
||||
)
|
||||
|
||||
// Stream lost → reconnect. The resubscribe path looks for a status:'ready'
|
||||
// handle and finds only the pending surface.
|
||||
|
|
@ -426,6 +430,9 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () =
|
|||
activateCallsAfterOutage,
|
||||
`reconnect ran ${listCallsAfterOutage} list-only inventory polls across online trigger + Reconnect click without ever activating the pending surface`
|
||||
).toBeGreaterThan(0)
|
||||
// Why: reconnect is machinery, not a user gesture, so the host must be able
|
||||
// to tell it apart and leave a deliberately slept pane slept (STA-3465).
|
||||
expect(activateIntentsAfterOutage.every((intent) => intent === 'automatic')).toBe(true)
|
||||
await vi.waitFor(() => expect(subscribedTerminalHandles()).toContain('terminal-2'))
|
||||
emitSnapshot(latestSubscribePayload().streamId, 'rematerialized')
|
||||
expect(transport.isConnected()).toBe(true)
|
||||
|
|
|
|||
|
|
@ -730,8 +730,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
terminal: 'terminal-1',
|
||||
viewport: { cols: 100, rows: 30 }
|
||||
})
|
||||
// Why: opening the pane is the user's wake gesture for a slept pane, so it
|
||||
// must not be labelled like the reconnect probe (STA-3465).
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'session.tabs.activate' })
|
||||
expect.objectContaining({
|
||||
method: 'session.tabs.activate',
|
||||
params: expect.objectContaining({ intent: 'user' })
|
||||
})
|
||||
)
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -4082,7 +4087,8 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
tabId: 'host-tab-1',
|
||||
leafId: 'leaf-1',
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -4233,7 +4239,8 @@ describe('createRemoteRuntimePtyTransport', () => {
|
|||
tabId: 'host-tab-1',
|
||||
leafId: 'leaf-2',
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
RuntimeEnsureAgentSessionResult
|
||||
} from '../../../../shared/agent-session-host-authority'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { TabActivationIntent } from '../../../../shared/tab-activation-intent'
|
||||
import type {
|
||||
RuntimeMobileSessionTerminalClientTab,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
|
|
@ -518,6 +519,7 @@ export function createRemoteRuntimePtyTransport(
|
|||
function activateHostSessionSurface(
|
||||
hostTabId: string,
|
||||
worktree: string,
|
||||
intent: TabActivationIntent,
|
||||
timeoutMs?: number
|
||||
): Promise<RuntimeMobileSessionTabsResult> {
|
||||
return callRuntime<RuntimeMobileSessionTabsResult>(
|
||||
|
|
@ -527,7 +529,8 @@ export function createRemoteRuntimePtyTransport(
|
|||
tabId: hostTabId,
|
||||
...(leafId ? { leafId } : {}),
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
|
|
@ -548,7 +551,8 @@ export function createRemoteRuntimePtyTransport(
|
|||
const worktree = toRuntimeWorktreeSelector(worktreeId)
|
||||
let activated: RuntimeMobileSessionTabsResult
|
||||
try {
|
||||
activated = await activateHostSessionSurface(hostTabId, worktree)
|
||||
// Why: this runs when the pane itself is opened/attached — the user's wake gesture.
|
||||
activated = await activateHostSessionSurface(hostTabId, worktree, 'user')
|
||||
} catch (error) {
|
||||
if (isMissingHostSessionSurfaceError(error)) {
|
||||
return null
|
||||
|
|
@ -714,7 +718,9 @@ export function createRemoteRuntimePtyTransport(
|
|||
requestRemainingMs
|
||||
)
|
||||
})
|
||||
: await activateHostSessionSurface(hostTabId, worktree, requestRemainingMs)
|
||||
: // Why: reconnect recovery, not a user gesture — a pane the user slept
|
||||
// must stay slept even though it publishes the same pending status.
|
||||
await activateHostSessionSurface(hostTabId, worktree, 'automatic', requestRemainingMs)
|
||||
lastRequestError = null
|
||||
const nextHandle = findReadyHostSessionHandle(listed, hostTabId)
|
||||
if (nextHandle) {
|
||||
|
|
|
|||
|
|
@ -1608,7 +1608,8 @@ describe('web runtime session tab actions', () => {
|
|||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'host-browser-unified',
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
|
|
|
|||
|
|
@ -907,9 +907,12 @@ async function callWebRuntimeSessionTabMethod(
|
|||
tabId: hostTabId,
|
||||
...(method === 'session.tabs.activate'
|
||||
? {
|
||||
// Why: the additive intent protects new hosts while notifyClients:false protects old hosts.
|
||||
// Why: the additive navigation target protects new hosts while notifyClients:false protects old hosts.
|
||||
notifyClients: false,
|
||||
navigation: 'caller' as const
|
||||
navigation: 'caller' as const,
|
||||
// Why: every caller here is a tab click, shortcut, or palette pick —
|
||||
// the gesture that is supposed to wake a slept pane.
|
||||
intent: 'user' as const
|
||||
}
|
||||
: {}),
|
||||
...(isLifecycleClose
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export const TAB_ACTIVATION_INTENTS = ['user', 'automatic'] as const
|
||||
|
||||
/**
|
||||
* Who asked for a tab activation: an explicit user gesture (opening the tab) or
|
||||
* background machinery (a reconnect/recovery probe). Opening a tab is the
|
||||
* documented way to wake a deliberately slept pane, so only an automatic
|
||||
* activation may be refused for one.
|
||||
*/
|
||||
export type TabActivationIntent = (typeof TAB_ACTIVATION_INTENTS)[number]
|
||||
|
||||
/**
|
||||
* Why: the field is additive on an existing method, so a client that predates it
|
||||
* sends nothing. Absent must keep today's permissive behavior or those clients
|
||||
* silently lose their wake gesture.
|
||||
*/
|
||||
export function isAutomaticTabActivation(intent: TabActivationIntent | undefined): boolean {
|
||||
return intent === 'automatic'
|
||||
}
|
||||
Loading…
Reference in New Issue