diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index 92935389af8..82916f3fd2b 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -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) diff --git a/src/main/daemon/daemon-session-info.ts b/src/main/daemon/daemon-session-info.ts new file mode 100644 index 00000000000..b9878b878c6 --- /dev/null +++ b/src/main/daemon/daemon-session-info.ts @@ -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 +} diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index aed0afdfa1e..2dcf9fda60f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -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) diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index b03edc95185..cd91a52691c 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -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() + +/** 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, 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(), diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 705f233d025..f3bbe5ffc61 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -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]) + }) }) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 3d1ad44e549..bf348eb6008 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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' diff --git a/src/main/ipc/pty/ipc/session-listing.ts b/src/main/ipc/pty/ipc/session-listing.ts index 6a4e1197b34..c5c2f6bcc08 100644 --- a/src/main/ipc/pty/ipc/session-listing.ts +++ b/src/main/ipc/pty/ipc/session-listing.ts @@ -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: diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index 3162c0b2561..bfae746f2ec 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -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. */ diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index d09857a1957..73434aa5c69 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -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 diff --git a/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts b/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts index f505993f740..2bd85cfc990 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts @@ -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]) + }) }) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 7323482c176..28ae695d235 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -105,11 +105,19 @@ function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boole function liveSleepingAgentClaims( store: ActivationStore, worktreeId: string, - livePtyIds: ReadonlySet, + liveSessions: readonly PtyListedSession[], structuredInventory: StructuredActivationInventory | null ): { keys: Set; claimedPtyIds: Set } { const keys = new Set() const claimedPtyIds = new Set() + 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( diff --git a/src/shared/pty-listed-session.ts b/src/shared/pty-listed-session.ts index 8ab0c26c2b1..39b9042c719 100644 --- a/src/shared/pty-listed-session.ts +++ b/src/shared/pty-listed-session.ts @@ -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