mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(remote): a paired client reopening a slept agent tab resumes its conversation (#29272)
* fix(remote): opening a slept agent pane from a paired client resumes its session When a paired client opened a pane whose PTY was gone, the host started a bare shell (or a fresh agent) instead of resuming the agent session it had recorded for that pane. Resume from the pane-owned record the way the host window's cold restore does. * fix(remote): label a resumed pane with the agent it resumes * fix(remote): do not resume a conversation another pane also records * fix(remote): send a pane resume through restore admission * fix(remote): do not resume a conversation already live in another pane * fix(remote): block a pane resume while another holder is not proven exited * fix(remote): do not resume a record the pane's hook identity has superseded * fix(remote): an unhydrated session holder blocks a pane resume * fix(remote): compare full provider-session identity in the resume guards
This commit is contained in:
@@ -50,18 +50,26 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function makeParkedRuntime(runtimeStore: unknown): {
|
||||
function makeParkedRuntime(
|
||||
runtimeStore: unknown,
|
||||
host: {
|
||||
deps?: ConstructorParameters<typeof OrcaRuntimeService>[2]
|
||||
livePtyIds?: string[]
|
||||
} = {}
|
||||
): {
|
||||
runtime: OrcaRuntimeService
|
||||
spawn: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' })
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store implements the runtime store methods these tests reach.
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, host.deps)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
listProcesses: async () =>
|
||||
(host.livePtyIds ?? []).map((id) => ({ id, cwd: '/tmp', title: 'Claude' }))
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
return { runtime, spawn }
|
||||
@@ -90,6 +98,21 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.markGraphReady(TEST_WINDOW_ID)
|
||||
}
|
||||
|
||||
function firstSpawnArgs(spawn: ReturnType<typeof vi.fn>): {
|
||||
command?: string
|
||||
sessionId?: string
|
||||
} {
|
||||
const args: unknown = spawn.mock.calls[0]?.[0]
|
||||
if (typeof args !== 'object' || args === null) {
|
||||
return {}
|
||||
}
|
||||
const command =
|
||||
'command' in args && typeof args.command === 'string' ? args.command : undefined
|
||||
const sessionId =
|
||||
'sessionId' in args && typeof args.sessionId === 'string' ? args.sessionId : undefined
|
||||
return { command, sessionId }
|
||||
}
|
||||
|
||||
const userActivate = (
|
||||
runtime: OrcaRuntimeService,
|
||||
leafId?: string
|
||||
@@ -190,6 +213,214 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
// #27147 / #27777: the tap used to start a bare shell (or a fresh agent), which lost the
|
||||
// conversation the host recorded for this pane. The host window resumes it; so must this.
|
||||
it('resumes the slept agent session when a paired client opens the pane', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {
|
||||
launchConfig: { agentArgs: '--dangerously-skip-permissions', agentEnv: {} }
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
setParkedRuntimeNotifier(runtime, vi.fn())
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only isDestroyed from the window mock.
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
|
||||
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'runtime'
|
||||
})
|
||||
|
||||
const activated = await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
const spawnArgs = firstSpawnArgs(spawn)
|
||||
expect(spawnArgs.sessionId).toBe('persisted-pty')
|
||||
expect(spawnArgs.command).toContain('--resume')
|
||||
expect(spawnArgs.command).toContain('provider-session-1')
|
||||
expect(spawnArgs.command).toContain('--dangerously-skip-permissions')
|
||||
expect(activated.tabs[0]).toMatchObject({ status: 'ready' })
|
||||
})
|
||||
|
||||
it('resumes from a live anchor when the pane was lost while its agent ran', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('live')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command).toContain('provider-session-1')
|
||||
})
|
||||
|
||||
// Why: Codex resume routing keys off the launch agent, so it must name the resumed agent.
|
||||
it('labels the spawn with the recorded agent, not the agent the tab first launched', async () => {
|
||||
const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep', {
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' }
|
||||
})
|
||||
const session = structuredClone(getSession())
|
||||
const tab = session.tabsByWorktree[TEST_WORKTREE_ID]?.[0]
|
||||
if (tab) {
|
||||
tab.launchAgent = 'claude'
|
||||
}
|
||||
setSession(session)
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command).toContain('codex-session-1')
|
||||
expect(spawn.mock.calls[0]?.[0]).toMatchObject({ launchAgent: 'codex' })
|
||||
})
|
||||
|
||||
it('does not resume a session another pane also records', async () => {
|
||||
const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep')
|
||||
const session = structuredClone(getSession())
|
||||
const records = session.sleepingAgentSessionsByPaneKey ?? {}
|
||||
const own = records[`host-tab:${HEADLESS_LEAF_ID}`]
|
||||
if (own) {
|
||||
records[`host-tab:${HEADLESS_SECOND_LEAF_ID}`] = {
|
||||
...own,
|
||||
paneKey: `host-tab:${HEADLESS_SECOND_LEAF_ID}`
|
||||
}
|
||||
}
|
||||
setSession(session)
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
// Why: a resume is restore work; paused or rate-limited restore admission must see it.
|
||||
it('declares a reconnect resume as automatic restore and a tap as a user resume', async () => {
|
||||
const automatic = makeParkedRuntime(makeParkedSessionStore('live').runtimeStore)
|
||||
await automaticActivate(automatic.runtime)
|
||||
expect(automatic.spawn.mock.calls[0]?.[0]).toMatchObject({
|
||||
spawnIntent: 'automatic-restore'
|
||||
})
|
||||
|
||||
const tap = makeParkedRuntime(makeParkedSessionStore('live').runtimeStore)
|
||||
await userActivate(tap.runtime)
|
||||
expect(tap.spawn.mock.calls[0]?.[0]).toMatchObject({ spawnIntent: 'user-resume' })
|
||||
})
|
||||
|
||||
function makeRuntimeWithOtherPaneHolder(livePtyIds: string[]) {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const parked = makeParkedRuntime(runtimeStore, {
|
||||
livePtyIds,
|
||||
deps: {
|
||||
getAgentProviderSessionSnapshot: () => [
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession.
|
||||
{
|
||||
paneKey: `other-tab:${HEADLESS_SECOND_LEAF_ID}`,
|
||||
providerSession: { key: 'session_id', id: 'provider-session-1' }
|
||||
} as never
|
||||
]
|
||||
}
|
||||
})
|
||||
parked.runtime.registerPty('other-pty', TEST_WORKTREE_ID, null, {
|
||||
tabId: 'other-tab',
|
||||
leafId: HEADLESS_SECOND_LEAF_ID
|
||||
})
|
||||
return parked
|
||||
}
|
||||
|
||||
it('does not resume a session the hook store places in another live pane', async () => {
|
||||
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder(['other-pty'])
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
// Why: losing sight of the holder (a dropped SSH relay) is not evidence it stopped writing.
|
||||
it('does not resume while the other pane holding the session is unverifiable', async () => {
|
||||
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([])
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
it('does not resume while the other holder has no host terminal record yet', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore, {
|
||||
deps: {
|
||||
getAgentProviderSessionSnapshot: () => [
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession.
|
||||
{
|
||||
paneKey: `other-tab:${HEADLESS_SECOND_LEAF_ID}`,
|
||||
providerSession: { key: 'session_id', id: 'provider-session-1' }
|
||||
} as never
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
it('resumes once the other pane holding the session has proven exited', async () => {
|
||||
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([])
|
||||
runtime.onPtyExit('other-pty', 0)
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command).toContain('provider-session-1')
|
||||
})
|
||||
|
||||
it('does not resume a record older than the pane conversation the hook store names', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore, {
|
||||
deps: {
|
||||
getAgentProviderSessionRowsForPane: (paneKey) =>
|
||||
paneKey === `host-tab:${HEADLESS_LEAF_ID}`
|
||||
? [
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only providerSession.
|
||||
{ paneKey, providerSession: { key: 'session_id', id: 'newer-session' } } as never
|
||||
]
|
||||
: []
|
||||
}
|
||||
})
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
// Why: Pi identities are id plus transcript; a shared id alone does not name one conversation.
|
||||
it('does not treat another Pi transcript with the same id as this pane session', async () => {
|
||||
const piSession = { key: 'session_id', id: 'pi-1', transcriptPath: '/t/a.jsonl' } as const
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {
|
||||
agent: 'pi',
|
||||
providerSession: piSession
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore, {
|
||||
deps: {
|
||||
getAgentProviderSessionRowsForPane: (paneKey) => [
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only providerSession.
|
||||
{ paneKey, providerSession: { ...piSession, transcriptPath: '/t/b.jsonl' } } as never
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('/t/a.jsonl')
|
||||
})
|
||||
|
||||
// Why: a session id names a transcript on the machine that captured it.
|
||||
it('does not resume a record captured on another execution host', async () => {
|
||||
const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {
|
||||
connectionId: 'ssh-elsewhere'
|
||||
})
|
||||
const { runtime, spawn } = makeParkedRuntime(runtimeStore)
|
||||
|
||||
await userActivate(runtime)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1')
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
|
||||
+113
-6
@@ -14,6 +14,14 @@ import {
|
||||
projectClientSessionTabSelection
|
||||
} from '../../client-session-tab-selection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
agentProviderSessionsEqual,
|
||||
type SleepingAgentSessionRecord
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import type {
|
||||
MobileTerminalResumeCommand,
|
||||
OrcaRuntimeWithResolveMobileSessionTerminalCommand as MobileTerminalStartup
|
||||
} from '../../terminal/launch/mobile-terminal-startup'
|
||||
import { runtimeWorktreeIdsEqual } from '../../runtime-worktree-path-identity'
|
||||
import {
|
||||
buildHeadlessMobileSessionTabGroups,
|
||||
@@ -24,6 +32,9 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc
|
||||
protected abstract emitMobileSessionTabsSnapshotToClient(
|
||||
...args: Parameters<MobileSessionPublication['emitMobileSessionTabsSnapshotToClient']>
|
||||
): ReturnType<MobileSessionPublication['emitMobileSessionTabsSnapshotToClient']>
|
||||
protected abstract resolveMobileSessionResumeCommand(
|
||||
...args: Parameters<MobileTerminalStartup['resolveMobileSessionResumeCommand']>
|
||||
): ReturnType<MobileTerminalStartup['resolveMobileSessionResumeCommand']>
|
||||
protected abstract isHeadlessMobileSessionPublication(
|
||||
...args: Parameters<MobileSessionPreservation['isHeadlessMobileSessionPublication']>
|
||||
): ReturnType<MobileSessionPreservation['isHeadlessMobileSessionPublication']>
|
||||
@@ -115,16 +126,112 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
// 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 this.getPaneSleepingAgentRecord(worktreeId, tab)?.origin === 'worktree-sleep'
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the host window's cold restore would resume this pane from, if the pane owns it.
|
||||
* Why: a running agent's worktree-sleep record is relaunched in a fresh tab by the window's
|
||||
* worktree activation, so resuming it in the pane as well would run one transcript twice.
|
||||
*/
|
||||
protected getPaneOwnedResumeRecord(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): SleepingAgentSessionRecord | null {
|
||||
const record = this.getPaneSleepingAgentRecord(worktreeId, tab)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const paneOwned =
|
||||
record.origin === 'live' || record.origin === 'quit' || record.state === 'done'
|
||||
return paneOwned ? record : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane's resume launch: undefined when it owns no record, `{}` (plain shell, record kept,
|
||||
* as the window does) when the record cannot be resumed here.
|
||||
*/
|
||||
protected async resolvePaneOwnedResumeStartup(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): Promise<MobileTerminalResumeCommand | Record<string, never> | undefined> {
|
||||
const record = this.getPaneOwnedResumeRecord(worktreeId, tab)
|
||||
if (!record) {
|
||||
return undefined
|
||||
}
|
||||
// Why: nothing here spends the record, so a session another pane also records could be
|
||||
// resumed twice (two writers on one transcript), as could one already live in another pane.
|
||||
const records = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey
|
||||
const sharedWithAnotherPane = Object.values(records ?? {}).some(
|
||||
(other) =>
|
||||
other !== record &&
|
||||
other.agent === record.agent &&
|
||||
agentProviderSessionsEqual(record.agent, other.providerSession, record.providerSession)
|
||||
)
|
||||
if (
|
||||
sharedWithAnotherPane ||
|
||||
this.isProviderSessionLiveInAnotherPane(record) ||
|
||||
this.hookStoreNamesAnotherSessionForPane(record)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
return this.resolveMobileSessionResumeCommand(workspace, record) ?? {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's hook store places this conversation in another pane not proven exited. */
|
||||
private isProviderSessionLiveInAnotherPane(record: SleepingAgentSessionRecord): boolean {
|
||||
const otherPaneKeys = new Set(
|
||||
(this.getAgentProviderSessionSnapshotFn?.() ?? [])
|
||||
.filter(
|
||||
(row) =>
|
||||
row.paneKey !== record.paneKey &&
|
||||
agentProviderSessionsEqual(record.agent, row.providerSession, record.providerSession)
|
||||
)
|
||||
.map((row) => row.paneKey)
|
||||
)
|
||||
if (otherPaneKeys.size === 0) {
|
||||
return false
|
||||
}
|
||||
// Why: only a proven exit clears a holder; an unverifiable PTY (dropped SSH relay) or one not
|
||||
// yet hydrated after a restart may still be writing the transcript.
|
||||
const provenExitedPaneKeys = new Set<string>()
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (pty.paneKey && otherPaneKeys.has(pty.paneKey)) {
|
||||
if (!this.isPtyKnownExited(pty.ptyId)) {
|
||||
return true
|
||||
}
|
||||
provenExitedPaneKeys.add(pty.paneKey)
|
||||
}
|
||||
}
|
||||
return [...otherPaneKeys].some((paneKey) => !provenExitedPaneKeys.has(paneKey))
|
||||
}
|
||||
|
||||
/** The pane moved on to another conversation after the record was saved; the hook store is authoritative. */
|
||||
private hookStoreNamesAnotherSessionForPane(record: SleepingAgentSessionRecord): boolean {
|
||||
return (this.getAgentProviderSessionRowsForPaneFn?.(record.paneKey) ?? []).some(
|
||||
(row) =>
|
||||
row.providerSession !== undefined &&
|
||||
!agentProviderSessionsEqual(record.agent, row.providerSession, record.providerSession)
|
||||
)
|
||||
}
|
||||
|
||||
private getPaneSleepingAgentRecord(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): SleepingAgentSessionRecord | null {
|
||||
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)
|
||||
)
|
||||
return record && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) ? record : null
|
||||
}
|
||||
|
||||
protected shouldMaterializeHeadlessMobileSessionTab(
|
||||
|
||||
+20
-4
@@ -1,7 +1,10 @@
|
||||
import type { RuntimeWorkspaceSelectors as WorkspaceSelectors } from '../../workspaces/workspace-selectors'
|
||||
import type { OrcaRuntimeWithApplyMobileSessionTabNavigation as MobileSessionNavigation } from './apply-mobile-session-tab-navigation'
|
||||
import type { OrcaRuntimeWithStopTerminalsForWorktree as WorktreeTerminalStop } from '../../terminal/stop/worktree-terminal-stop'
|
||||
import type { MobileTerminalCommand } from '../../terminal/launch/mobile-terminal-startup'
|
||||
import type {
|
||||
MobileTerminalCommand,
|
||||
MobileTerminalResumeCommand
|
||||
} from '../../terminal/launch/mobile-terminal-startup'
|
||||
import { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs } from './build-headless-mobile-session-browser-tabs'
|
||||
import type { PtyControllerInventory } from '../../runtime-pty-controller-contract'
|
||||
import { isAgentLaunchRunningIn } from '../../../agent-launch/agent-launch-pane-attachment'
|
||||
@@ -32,6 +35,9 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten
|
||||
protected abstract isDeliberatelyParkedPane(
|
||||
...args: Parameters<MobileSessionNavigation['isDeliberatelyParkedPane']>
|
||||
): ReturnType<MobileSessionNavigation['isDeliberatelyParkedPane']>
|
||||
protected abstract resolvePaneOwnedResumeStartup(
|
||||
...args: Parameters<MobileSessionNavigation['resolvePaneOwnedResumeStartup']>
|
||||
): ReturnType<MobileSessionNavigation['resolvePaneOwnedResumeStartup']>
|
||||
protected abstract isWorktreeTerminalSleepHeld(
|
||||
...args: Parameters<WorktreeTerminalStop['isWorktreeTerminalSleepHeld']>
|
||||
): ReturnType<WorktreeTerminalStop['isWorktreeTerminalSleepHeld']>
|
||||
@@ -199,8 +205,11 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten
|
||||
// bare materialize would put a plain shell under the agent icon.
|
||||
// Re-resolve the launch like the create path; providers skip startup
|
||||
// commands when attaching to live sessions, so this cannot double-launch.
|
||||
let agentStartup: MobileTerminalCommand = {}
|
||||
if (tab.launchAgent) {
|
||||
// Why: an agent already ran here and the host recorded its session; resume it rather than
|
||||
// start a bare shell or a fresh agent (#27147, #27777).
|
||||
const resumeStartup = await this.resolvePaneOwnedResumeStartup(worktreeId, tab)
|
||||
let agentStartup: MobileTerminalCommand | MobileTerminalResumeCommand = resumeStartup ?? {}
|
||||
if (resumeStartup === undefined && tab.launchAgent) {
|
||||
let workspace
|
||||
try {
|
||||
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
@@ -228,7 +237,14 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten
|
||||
env: agentStartup.env,
|
||||
startupCommandDelivery: agentStartup.startupCommandDelivery,
|
||||
launchConfig: agentStartup.launchConfig,
|
||||
launchAgent: tab.launchAgent,
|
||||
launchAgent: agentStartup.launchAgent ?? tab.launchAgent,
|
||||
// Why: a resume is restore work, so the host's restore admission must see it.
|
||||
...('resumeProviderSession' in agentStartup
|
||||
? {
|
||||
resumeProviderSession: agentStartup.resumeProviderSession,
|
||||
spawnIntent: automatic ? 'automatic-restore' : 'user-resume'
|
||||
}
|
||||
: {}),
|
||||
launchRuntime: agentStartup.launchRuntime,
|
||||
targetGroupId,
|
||||
...(automatic ? { refuseSleptWorktree: true } : {})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup'
|
||||
import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
|
||||
import type {
|
||||
AgentProviderSessionMetadata,
|
||||
SleepingAgentLaunchConfig
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import type { SpawnIntent } from '../../../../shared/host-spawn-admission'
|
||||
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
|
||||
import type {
|
||||
RuntimeMobileSessionCreateTerminalResult,
|
||||
@@ -34,6 +38,8 @@ export type OwnedMobileTerminalCreateOptions = {
|
||||
targetGroupId?: string
|
||||
supportsSplitGroupPlacement?: boolean
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
spawnIntent?: SpawnIntent
|
||||
launchRuntime?: AgentLaunchRuntime
|
||||
signal?: AbortSignal
|
||||
refuseSleptWorktree?: boolean
|
||||
@@ -112,6 +118,10 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
|
||||
...(opts.resumeProviderSession
|
||||
? { resumeProviderSession: opts.resumeProviderSession }
|
||||
: {}),
|
||||
...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}),
|
||||
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
||||
|
||||
@@ -2,9 +2,22 @@ import { OrcaRuntimeWithRunCreateMobileSessionTerminal } from './mobile-terminal
|
||||
import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
|
||||
import {
|
||||
isResumableTuiAgent,
|
||||
normalizeAgentProviderSession,
|
||||
type AgentProviderSessionMetadata,
|
||||
type SleepingAgentLaunchConfig,
|
||||
type SleepingAgentSessionRecord
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import { buildAgentStartupPlan } from '../../../../shared/tui-agent-startup'
|
||||
import {
|
||||
buildAgentResumeStartupPlan,
|
||||
buildAgentStartupPlan
|
||||
} from '../../../../shared/tui-agent-startup'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { resolveAgentStartupPlanInputs } from '../../../../shared/agent-startup-plan-inputs'
|
||||
import {
|
||||
agentLaunchQuotingShell,
|
||||
@@ -33,7 +46,71 @@ export type MobileTerminalCommand = {
|
||||
launchRuntime?: AgentLaunchRuntime
|
||||
}
|
||||
|
||||
export type MobileTerminalResumeCommand = MobileTerminalCommand & {
|
||||
command: string
|
||||
resumeProviderSession: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
function normalizeOriginConnectionId(connectionId: string | null | undefined): string | null {
|
||||
return connectionId?.trim() || null
|
||||
}
|
||||
|
||||
export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal {
|
||||
/** The `--resume` launch for a pane's recorded agent session, as the host window's cold restore builds it. */
|
||||
protected resolveMobileSessionResumeCommand(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
record: SleepingAgentSessionRecord
|
||||
): MobileTerminalResumeCommand | null {
|
||||
const providerSession = normalizeAgentProviderSession(record.providerSession)
|
||||
if (!this.store || !providerSession || !isResumableTuiAgent(record.agent)) {
|
||||
return null
|
||||
}
|
||||
// Why: a session id names a transcript on the machine that captured it; elsewhere it cannot resume.
|
||||
if (
|
||||
record.connectionId !== undefined &&
|
||||
normalizeOriginConnectionId(record.connectionId) !==
|
||||
normalizeOriginConnectionId(workspace.connectionId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
if (!isTuiAgentEnabled(record.agent, settings.disabledTuiAgents)) {
|
||||
return null
|
||||
}
|
||||
const launchRuntime = this.resolveAgentLaunchRuntime(workspace, record.agent)
|
||||
const { launchConfig } = record
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: record.agent,
|
||||
providerSession,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: launchConfig
|
||||
? launchConfig.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(record.agent, settings.agentDefaultArgs),
|
||||
agentEnv: launchConfig
|
||||
? launchConfig.agentEnv
|
||||
: resolveTuiAgentLaunchEnv(record.agent, settings.agentDefaultEnv),
|
||||
...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
|
||||
...(launchConfig?.ompResumeFilePath
|
||||
? { ompResumeFilePath: launchConfig.ompResumeFilePath }
|
||||
: {}),
|
||||
platform: launchRuntime.platform,
|
||||
shell: agentLaunchQuotingShell(launchRuntime),
|
||||
isRemote: Boolean(workspace.connectionId)
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
command: startupPlan.launchCommand,
|
||||
env: startupPlan.env,
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
launchAgent: record.agent,
|
||||
startupCommandDelivery: startupPlan.startupCommandDelivery,
|
||||
launchRuntime,
|
||||
resumeProviderSession: providerSession
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolveMobileSessionTerminalCommand(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: MobileTerminalCommandOptions
|
||||
|
||||
Reference in New Issue
Block a user