mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(terminal): don't resume an agent whose spawned PTY is still live (#33380)
The daemon records each terminal's spawn pane key and reports it in session listings; the sleeping-agent activation gate uses it as an exact-match fallback to find a live agent PTY after the host re-keys its tab, so paired clients and phone wake no longer start a duplicate `claude --resume`. Fixes #21235, #24385. Supersedes #24386. Co-authored-by: Minidoracat <11269639+Minidoracat@users.noreply.github.com>
This commit is contained in:
@@ -51,6 +51,7 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti
|
||||
...(session.foregroundProcess ? { foregroundProcess: session.foregroundProcess } : {}),
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.paneKey ? { paneKey: session.paneKey } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...(session.state === 'exiting' ? { exiting: true as const } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import type { SessionState, ShellReadyState } from './types'
|
||||
|
||||
export type SessionInfo = {
|
||||
foregroundProcess?: string
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
/** ORCA_PANE_KEY the PTY was spawned with; absent from daemons that predate it. */
|
||||
paneKey?: string
|
||||
wslDistro?: string | null
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
createdAt: number
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
|
||||
/** Router/adapter metadata for legacy-session labels; never transmitted on the daemon wire. */
|
||||
export type DaemonSessionInfo = SessionInfo & {
|
||||
protocolVersion: number
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
|
||||
import { SessionNotFoundError } from './types'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
import { recordSessionSpawnPaneKey } from './terminal-host-session-listing'
|
||||
import {
|
||||
attachSessionInputViewer,
|
||||
isSessionInputClosed,
|
||||
@@ -199,6 +200,7 @@ async function spawnAndPublishSession(
|
||||
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
|
||||
: {})
|
||||
})
|
||||
recordSessionSpawnPaneKey(session, opts.env?.ORCA_PANE_KEY)
|
||||
|
||||
trackSessionInput(session, subprocess, deps.reportReadinessEvent)
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@ import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pt
|
||||
import type { Session } from './session'
|
||||
import type { SessionInfo } from './types'
|
||||
|
||||
// Why a side table: only the listing reads it, and Session is at its max-lines cap.
|
||||
const spawnPaneKeys = new WeakMap<Session, string>()
|
||||
|
||||
/** Records the ORCA_PANE_KEY a session was spawned with; reattaching never rewrites it. */
|
||||
export function recordSessionSpawnPaneKey(session: Session, paneKey: string | undefined): void {
|
||||
if (paneKey) {
|
||||
spawnPaneKeys.set(session, paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function listLiveTerminalHostSessions(
|
||||
sessions: ReadonlyMap<string, Session>,
|
||||
agentSessionOwners: ClaimedAgentPtyOwnerRegistry
|
||||
@@ -12,6 +22,7 @@ export function listLiveTerminalHostSessions(
|
||||
continue
|
||||
}
|
||||
const size = session.getAppliedSize()
|
||||
const paneKey = spawnPaneKeys.get(session)
|
||||
const foregroundProcess = session.getForegroundProcess({ cachedOnly: true })
|
||||
result.push({
|
||||
sessionId: session.sessionId,
|
||||
@@ -22,6 +33,7 @@ export function listLiveTerminalHostSessions(
|
||||
shellState: session.shellState,
|
||||
isAlive: true,
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(paneKey ? { paneKey } : {}),
|
||||
wslDistro: session.wslDistro,
|
||||
pid: session.pid,
|
||||
cwd: session.getCwd(),
|
||||
|
||||
@@ -772,4 +772,27 @@ describe('TerminalHost', () => {
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// The activation gate claims sleeping records by this key, so reattaching must not rewrite it.
|
||||
it('lists the pane key a session was spawned for, not the pane that reattaches it', async () => {
|
||||
const spawn = (paneKey: string) =>
|
||||
host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { ORCA_PANE_KEY: paneKey },
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
const launchedFor = 'client-tab:11111111-1111-4111-8111-111111111111'
|
||||
const respawnedFor = 'desktop-tab:22222222-2222-4222-8222-222222222222'
|
||||
|
||||
await spawn(launchedFor)
|
||||
await spawn(respawnedFor)
|
||||
expect(host.listSessions().map((session) => session.paneKey)).toEqual([launchedFor])
|
||||
|
||||
lastSubprocess._onExitCb?.(0)
|
||||
await vi.waitFor(() => expect(host.listSessions()).toHaveLength(0))
|
||||
await spawn(respawnedFor)
|
||||
expect(host.listSessions().map((session) => session.paneKey)).toEqual([respawnedFor])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SystemResolverHealthRequest
|
||||
} from './daemon-health-requests'
|
||||
import type { SpawnIntent } from '../../shared/host-spawn-admission'
|
||||
import type { SessionInfo } from './daemon-session-info'
|
||||
import type {
|
||||
ConfirmForegroundProcessRequest,
|
||||
ConfirmShellForegroundRequest,
|
||||
@@ -25,7 +26,6 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
|
||||
import type {
|
||||
AgentSessionExecutionClaim,
|
||||
AgentSessionOwnerBinding,
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol'
|
||||
@@ -321,27 +321,7 @@ export type SystemResolverHealthResult = {
|
||||
health: SystemResolverHealth
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
foregroundProcess?: string
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
wslDistro?: string | null
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
createdAt: number
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
|
||||
/** Router/adapter metadata for legacy-session labels; never transmitted on the daemon wire. */
|
||||
export type DaemonSessionInfo = SessionInfo & {
|
||||
protocolVersion: number
|
||||
}
|
||||
export type { DaemonSessionInfo, SessionInfo } from './daemon-session-info'
|
||||
|
||||
export type * from './daemon-stream-events'
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export function installPtySessionListingIpcHandler(deps: {
|
||||
hostId === LOCAL_EXECUTION_HOST_ID &&
|
||||
provider.supportsFencedTermination?.(session.id) === true,
|
||||
...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}),
|
||||
...(session.paneKey !== undefined ? { paneKey: session.paneKey } : {}),
|
||||
...(session.exiting === true ? { exiting: true as const } : {}),
|
||||
// During restore, claims provide liveness evidence; only listing-capable providers can prove ownership absent (#8459).
|
||||
agentOwnership:
|
||||
|
||||
@@ -19,6 +19,8 @@ export type PtyProcessInfo = {
|
||||
worktreeId?: string
|
||||
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
|
||||
terminalHandle?: string
|
||||
/** ORCA_PANE_KEY this PTY was spawned with, when the provider recorded it. */
|
||||
paneKey?: string
|
||||
/** Exact WSL owner reported by the PTY provider; null means native Windows. */
|
||||
wslDistro?: string | null
|
||||
/** Optional host-side process evidence attached to an inventory seed. */
|
||||
|
||||
@@ -57,6 +57,7 @@ export class PtyProcessListAdmission {
|
||||
const inventorySourceBytes = retainedOptionalStringBytes(value.inventorySourceId)
|
||||
const worktreeIdBytes = retainedOptionalStringBytes(value.worktreeId)
|
||||
const terminalHandleBytes = retainedOptionalStringBytes(value.terminalHandle)
|
||||
const paneKeyBytes = retainedOptionalStringBytes(value.paneKey)
|
||||
const wslDistroBytes =
|
||||
value.wslDistro === null ? 0 : retainedOptionalStringBytes(value.wslDistro)
|
||||
const evidenceBytes =
|
||||
@@ -74,6 +75,7 @@ export class PtyProcessListAdmission {
|
||||
(value.routed !== undefined && typeof value.routed !== 'boolean') ||
|
||||
worktreeIdBytes === null ||
|
||||
terminalHandleBytes === null ||
|
||||
paneKeyBytes === null ||
|
||||
wslDistroBytes === null ||
|
||||
evidenceBytes === null ||
|
||||
(value.rootProcessId !== undefined &&
|
||||
@@ -110,6 +112,7 @@ export class PtyProcessListAdmission {
|
||||
inventorySourceBytes +
|
||||
worktreeIdBytes +
|
||||
terminalHandleBytes +
|
||||
paneKeyBytes +
|
||||
wslDistroBytes +
|
||||
evidenceBytes +
|
||||
ownerBytes
|
||||
@@ -139,6 +142,7 @@ export class PtyProcessListAdmission {
|
||||
...(value.rootProcessId !== undefined ? { rootProcessId: value.rootProcessId } : {}),
|
||||
...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}),
|
||||
...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}),
|
||||
...(value.paneKey !== undefined ? { paneKey: value.paneKey } : {}),
|
||||
...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}),
|
||||
...(value.exiting === true ? { exiting: true as const } : {}),
|
||||
...(value.foregroundProcessEvidence !== undefined
|
||||
|
||||
@@ -155,4 +155,49 @@ describe('background wake of a pane whose PTY the host still holds', () => {
|
||||
|
||||
expect(queuedResumes()).toEqual([SESSION])
|
||||
})
|
||||
|
||||
// #21235: the host re-keyed the live PTY onto a new tab, so the record's pane has no layout.
|
||||
it('does not resume an agent whose PTY the host moved to another tab', async () => {
|
||||
const state = unmountedLivePaneState()
|
||||
useAppStore.setState({
|
||||
...state,
|
||||
tabsByWorktree: {
|
||||
[WT]: [{ ...state.tabsByWorktree![WT]![0]!, id: 'tab-rekeyed', ptyId: LIVE_PTY_ID }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-rekeyed': [LIVE_PTY_ID] },
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
// A fresh `claude` launch registers no owner claim; only its spawn pane key ties it to the record.
|
||||
listedSessions = [
|
||||
{
|
||||
id: LIVE_PTY_ID,
|
||||
cwd: '/repo',
|
||||
title: 'claude',
|
||||
agentOwnership: 'absent',
|
||||
paneKey: PANE_KEY
|
||||
}
|
||||
]
|
||||
|
||||
await wakeAndSettle()
|
||||
|
||||
expect(queuedResumes()).toEqual([])
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeDefined()
|
||||
})
|
||||
|
||||
it('still resumes a dead agent next to a shell spawned for another pane', async () => {
|
||||
useAppStore.setState({ ...unmountedLivePaneState(), terminalLayoutsByTabId: {} })
|
||||
listedSessions = [
|
||||
{
|
||||
id: `${WT}@@sibling-shell`,
|
||||
cwd: '/repo',
|
||||
title: 'zsh',
|
||||
agentOwnership: 'absent',
|
||||
paneKey: makePaneKey('tab-live', '22222222-2222-4222-8222-222222222222')
|
||||
}
|
||||
]
|
||||
|
||||
expect(await wakeAndSettle()).toBe('resumed')
|
||||
|
||||
expect(queuedResumes()).toEqual([SESSION])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,11 +105,19 @@ function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boole
|
||||
function liveSleepingAgentClaims(
|
||||
store: ActivationStore,
|
||||
worktreeId: string,
|
||||
livePtyIds: ReadonlySet<string>,
|
||||
liveSessions: readonly PtyListedSession[],
|
||||
structuredInventory: StructuredActivationInventory | null
|
||||
): { keys: Set<string>; claimedPtyIds: Set<string> } {
|
||||
const keys = new Set<string>()
|
||||
const claimedPtyIds = new Set<string>()
|
||||
const livePtyIds = new Set(liveSessions.map((session) => session.id))
|
||||
// Why: the host can re-key a live PTY onto a new tab (#21235), or a paired client spawned it for a
|
||||
// pane this renderer never had; the spawn pane key is still the one the agent reports from.
|
||||
const livePtyIdBySpawnPaneKey = new Map(
|
||||
liveSessions.flatMap((session) =>
|
||||
session.paneKey ? [[session.paneKey, session.id] as const] : []
|
||||
)
|
||||
)
|
||||
for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
|
||||
if (record.worktreeId !== worktreeId) {
|
||||
continue
|
||||
@@ -129,8 +137,12 @@ function liveSleepingAgentClaims(
|
||||
continue
|
||||
}
|
||||
const persistedPtyId = layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined)
|
||||
if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
|
||||
claimedPtyIds.add(persistedPtyId)
|
||||
const livePtyId =
|
||||
persistedPtyId && livePtyIds.has(persistedPtyId)
|
||||
? persistedPtyId
|
||||
: livePtyIdBySpawnPaneKey.get(record.paneKey)
|
||||
if (livePtyId) {
|
||||
claimedPtyIds.add(livePtyId)
|
||||
keys.add(getProviderSessionClaimKey(record))
|
||||
}
|
||||
}
|
||||
@@ -197,7 +209,6 @@ export async function runWorktreeAgentActivationGate(
|
||||
(session.worktreeId !== undefined && worktreeIdsEqual(session.worktreeId, worktreeId)) ||
|
||||
sessionBelongsToWorkspace(session.id, worktreeId)
|
||||
)
|
||||
const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id))
|
||||
// Why: a session the host is killing was closed by the user (often just before a quit); a
|
||||
// surface for it would bring the closed pane back. It still counts as live work below.
|
||||
const adoptablePtyIds = liveWorkspaceSessions
|
||||
@@ -234,7 +245,7 @@ export async function runWorktreeAgentActivationGate(
|
||||
const claims = liveSleepingAgentClaims(
|
||||
store,
|
||||
worktreeId,
|
||||
liveWorkspacePtyIds,
|
||||
liveWorkspaceSessions,
|
||||
structuredInventory
|
||||
)
|
||||
const hasUnclaimedRecovery = Object.values(store.sleepingAgentSessionsByPaneKey).some(
|
||||
|
||||
@@ -41,6 +41,8 @@ export type PtyListedSession = {
|
||||
cwd: string
|
||||
title: string
|
||||
worktreeId?: string
|
||||
/** Pane key the PTY was spawned with; reattaching from another pane never changes it. */
|
||||
paneKey?: string
|
||||
/**
|
||||
* Agent ownership as the listing provider could establish it. Destructive actions must treat
|
||||
* anything other than `absent` as live work: discarding this distinction is what let Resource
|
||||
|
||||
Reference in New Issue
Block a user