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:
Neil
2026-10-24 20:51:22 -07:00
committed by GitHub
co-authored by Minidoracat
parent 7d0103e8a6
commit b94ad9870e
12 changed files with 136 additions and 27 deletions
@@ -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)
+26
View File
@@ -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(),
+23
View File
@@ -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])
})
})
+2 -22
View File
@@ -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'
+1
View File
@@ -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:
+2
View File
@@ -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(
+2
View File
@@ -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