From d57a480caaa4f7b897572458b818fb2c4b98bf76 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 1 Sep 2026 12:54:19 -0700 Subject: [PATCH] fix(pty): gate resumes by relay epoch --- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 22 +-- src/main/ipc/pty/ipc/spawn-commit.ts | 35 ++-- src/main/ipc/pty/ipc/spawn-options.ts | 3 + src/main/ipc/pty/pane/relay-pty-mint-epoch.ts | 98 +++++++++++ .../pty/pane/stable-owner-relay-epoch.test.ts | 164 ++++++++++++++++++ src/main/ipc/pty/pane/stable-owner.ts | 14 +- src/main/ipc/pty/runtime/spawn-commit.ts | 37 ++-- src/main/ipc/pty/runtime/spawn-options.ts | 3 + src/main/ipc/pty/runtime/spawn.ts | 4 +- src/main/providers/pty-provider-contract.ts | 3 + src/main/providers/pty-spawn-result.ts | 2 + .../runtime/orca-runtime-create-terminal.ts | 25 ++- .../runtime-pty-controller-contract.ts | 1 + src/relay/pty-handler.ts | 4 + src/relay/relay-daemon.ts | 1 + src/relay/subprocess.test.ts | 2 + ...nnection-cold-restore-agent-resume.test.ts | 3 + .../pty-connection/apply-reattach-payload.ts | 8 +- .../connect-pane-pty-session.ts | 5 + .../pty-connection/connect-pane-pty.ts | 1 + .../pty-connection/declined-agent-resume.ts | 22 +++ .../pty-connection/fresh-spawn-start.ts | 19 +- .../pty-connection/pane-serializer-settle.ts | 5 + .../reattach-payload-session.ts | 5 + .../remote-runtime-pty-transport.ts | 5 +- src/shared/runtime-terminal-contracts.ts | 1 + 26 files changed, 428 insertions(+), 64 deletions(-) create mode 100644 src/main/ipc/pty/pane/relay-pty-mint-epoch.ts create mode 100644 src/main/ipc/pty/pane/stable-owner-relay-epoch.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/declined-agent-resume.ts diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index be9cb31394a..ff7b6839607 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -41,16 +41,18 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ daemon: ctx.isDaemonHostSpawn, reattach: ctx.result.isReattach ?? false }) - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: ctx.baseEnv, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) + if (!ctx.result.agentResumeUnavailable) { + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: ctx.baseEnv, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + } ptyOwnership.set(ctx.result.id, args.connectionId ?? null) if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index f02eb215c87..0b1b6f0db26 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -27,6 +27,7 @@ import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + const agentResumeDeclined = ctx.result.agentResumeUnavailable === true const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). @@ -76,15 +77,17 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0 && args.worktreeId.length <= 512 ) { - const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ - launchToken: args.launchToken, - spawnEnv: ctx.spawnEnv, - launchAgent: args.launchAgent, - launchConfig: ctx.effectiveLaunchConfig, - isReattach: ctx.result.isReattach === true, - hasStablePaneOwner: ctx.stablePaneOwner !== null, - incarnationId: ctx.result.incarnationId - }) + const agentLaunchAuthority = agentResumeDeclined + ? null + : admitRendererAgentLaunchAuthority({ + launchToken: args.launchToken, + spawnEnv: ctx.spawnEnv, + launchAgent: args.launchAgent, + launchConfig: ctx.effectiveLaunchConfig, + isReattach: ctx.result.isReattach === true, + hasStablePaneOwner: ctx.stablePaneOwner !== null, + incarnationId: ctx.result.incarnationId + }) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({ isReattach: ctx.result.isReattach === true, launchAgent: ctx.result.launchAgent, @@ -125,13 +128,13 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise>() + +function parseRelayPtyMintEpoch(relayPtyId: string): string | undefined { + const match = /^pty2:([^:]+):(\d+)$/.exec(relayPtyId) + if (!match || !Number.isSafeInteger(Number(match[2]))) { + return undefined + } + try { + const epoch = decodeURIComponent(match[1]) + return epoch.length > 0 ? epoch : undefined + } catch { + return undefined + } +} + +function readRelayMintEpoch(provider: IPtyProvider): Promise { + const cached = relayMintEpochByProvider.get(provider) + if (cached) { + return cached + } + const read = provider + .requestHostRpc?.('relay.status', {}, { timeoutMs: RELAY_STATUS_TIMEOUT_MS }) + .then((status) => { + if (!status || typeof status !== 'object') { + return undefined + } + const epoch = (status as { ptyIdMintEpoch?: unknown }).ptyIdMintEpoch + return typeof epoch === 'string' && epoch.length > 0 ? epoch : undefined + }) + .catch(() => undefined) + const result = read ?? Promise.resolve(undefined) + relayMintEpochByProvider.set(provider, result) + return result +} + +export async function compareStablePaneRelayEpoch(args: { + provider: IPtyProvider + ownerPtyId: string + connectionId: string | null | undefined +}): Promise<'same' | 'different' | 'unknown'> { + if (!args.connectionId) { + return 'unknown' + } + const relayPtyId = parseAppSshPtyId(args.ownerPtyId)?.relayPtyId + const ownerEpoch = relayPtyId ? parseRelayPtyMintEpoch(relayPtyId) : undefined + if (!ownerEpoch || !args.provider.requestHostRpc) { + return 'unknown' + } + const currentEpoch = await readRelayMintEpoch(args.provider) + if (!currentEpoch) { + return 'unknown' + } + return currentEpoch === ownerEpoch ? 'same' : 'different' +} + +function stripAgentResumeOptions(options: PtySpawnOptions): PtySpawnOptions { + const stripped = { ...options } + delete stripped.launchAgent + delete stripped.command + delete stripped.commandDelivery + delete stripped.startupCommandDelivery + delete stripped.resumeProviderSession + delete stripped.startupIngress + delete stripped.agentSessionEnsure + delete stripped.agentSessionCreateOperationId + if (stripped.env) { + stripped.env = { ...stripped.env } + delete stripped.env.ORCA_AGENT_LAUNCH_TOKEN + } + stripped.envToDelete = [...new Set([...(stripped.envToDelete ?? []), 'ORCA_AGENT_LAUNCH_TOKEN'])] + return stripped +} + +export async function deriveStablePaneFreshSpawnOptions(args: { + provider: IPtyProvider + ownerPtyId: string + connectionId: string | null | undefined + spawnOptions: PtySpawnOptions +}): Promise<{ options: PtySpawnOptions; agentResumeDeclined: boolean }> { + const verdict = await compareStablePaneRelayEpoch(args) + const hasAgentResumeIntent = Boolean( + args.spawnOptions.launchAgent || + args.spawnOptions.resumeProviderSession || + args.spawnOptions.agentSessionEnsure + ) + // Compatibility: legacy pty-N ids, old relays, RPC failure/timeout, and non-SSH panes are no + // verdict and resume as today. We grandfather them because ordinary absence means the same + // relay lost its PTY; residual risk requires a legacy id, relay replacement, and surviving orphan. + const agentResumeDeclined = verdict === 'different' && hasAgentResumeIntent + return { + options: agentResumeDeclined ? stripAgentResumeOptions(args.spawnOptions) : args.spawnOptions, + agentResumeDeclined + } +} diff --git a/src/main/ipc/pty/pane/stable-owner-relay-epoch.test.ts b/src/main/ipc/pty/pane/stable-owner-relay-epoch.test.ts new file mode 100644 index 00000000000..5f5a7b71233 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner-relay-epoch.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import { toAppSshPtyId } from '../../../providers/ssh-pty-id' +import { spawnForStablePane, type StablePaneOwner } from './stable-owner' + +type EpochAwareSpawnOptions = PtySpawnOptions & { resumeProviderSession?: unknown } +type EpochAwareSpawnResult = PtySpawnResult & { agentResumeUnavailable?: true } + +const owner = (relayPtyId: string): StablePaneOwner => ({ + tabId: 'tab-epoch-gate', + leafId: '11111111-1111-4111-8111-111111111111', + ptyId: toAppSshPtyId('remote', relayPtyId) +}) + +const agentSpawnOptions = (): EpochAwareSpawnOptions => ({ + cols: 100, + rows: 30, + cwd: '/workspace', + env: { KEEP: 'yes', ORCA_AGENT_LAUNCH_TOKEN: 'launch-token' }, + envToDelete: ['DELETE_ME'], + command: 'codex resume session', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready', + launchAgent: 'codex', + resumeProviderSession: { key: 'session_id', id: 'session' }, + startupIngress: { colors: { foreground: '#ffffff' }, deadlineMs: 5_000 }, + agentSessionEnsure: {} as never, + agentSessionCreateOperationId: 'create-operation' +}) + +function createProvider(status: unknown | Error) { + const spawns: EpochAwareSpawnOptions[] = [] + const requestHostRpc = vi.fn(async () => { + if (status instanceof Error) { + throw status + } + return status + }) + const provider = { + requestHostRpc, + spawn: vi.fn(async (options: EpochAwareSpawnOptions) => { + spawns.push(options) + if (options.attachOnly) { + throw new Error(`PTY "${options.sessionId}" not found`) + } + return { id: toAppSshPtyId('remote', 'pty2:current:2') } + }) + } as unknown as IPtyProvider + return { provider, requestHostRpc, spawns } +} + +async function runSpawn( + relayPtyId: string, + status: unknown | Error, + spawnOptions: EpochAwareSpawnOptions = agentSpawnOptions() +) { + const harness = createProvider(status) + let freshResult: EpochAwareSpawnResult | undefined + const spawned = await spawnForStablePane({ + runtime: undefined, + provider: harness.provider, + spawnOptions, + owner: owner(relayPtyId), + connectionId: 'remote', + onFreshSpawn: (result) => { + freshResult = result as EpochAwareSpawnResult + } + }) + return { + ...harness, + freshOptions: harness.spawns[1], + result: spawned.result as EpochAwareSpawnResult, + freshResult + } +} + +describe('spawnForStablePane relay epoch gate', () => { + it('declines an agent resume owned by a different relay epoch', async () => { + const { freshOptions, result, freshResult, requestHostRpc } = await runSpawn( + 'pty2:previous:1', + { ptyIdMintEpoch: 'current' } + ) + + expect(requestHostRpc).toHaveBeenCalledWith( + 'relay.status', + {}, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + expect(freshOptions).toMatchObject({ + cols: 100, + rows: 30, + cwd: '/workspace', + env: { KEEP: 'yes' }, + envToDelete: expect.arrayContaining(['DELETE_ME', 'ORCA_AGENT_LAUNCH_TOKEN']) + }) + expect(freshOptions).not.toHaveProperty('launchAgent') + expect(freshOptions).not.toHaveProperty('command') + expect(freshOptions).not.toHaveProperty('commandDelivery') + expect(freshOptions).not.toHaveProperty('startupCommandDelivery') + expect(freshOptions).not.toHaveProperty('resumeProviderSession') + expect(freshOptions).not.toHaveProperty('startupIngress') + expect(freshOptions).not.toHaveProperty('agentSessionEnsure') + expect(freshOptions).not.toHaveProperty('agentSessionCreateOperationId') + expect(result.agentResumeUnavailable).toBe(true) + expect(freshResult?.agentResumeUnavailable).toBe(true) + }) + + it('preserves an agent resume owned by the current relay epoch', async () => { + const spawnOptions = agentSpawnOptions() + const { freshOptions, result } = await runSpawn('pty2:current:1', { + ptyIdMintEpoch: 'current' + }) + + expect(freshOptions).toEqual(spawnOptions) + expect(result.agentResumeUnavailable).toBeUndefined() + }) + + it.each([ + ['legacy owner id', 'pty-1', { ptyIdMintEpoch: 'current' }], + ['unknown relay epoch', 'pty2:previous:1', {}], + ['relay status failure', 'pty2:previous:1', new Error('relay unavailable')] + ])('preserves current behavior for %s', async (_label, relayPtyId, status) => { + const spawnOptions = agentSpawnOptions() + const { freshOptions, result } = await runSpawn(relayPtyId, status) + + expect(freshOptions).toEqual(spawnOptions) + expect(result.agentResumeUnavailable).toBeUndefined() + }) + + it('decodes the epoch embedded in an app-facing SSH PTY id', async () => { + const spawnOptions = agentSpawnOptions() + const { freshOptions } = await runSpawn('pty2:relay%3Aepoch:1', { + ptyIdMintEpoch: 'relay:epoch' + }) + + expect(freshOptions).toEqual(spawnOptions) + }) + + it('reads relay status only once per provider connection generation', async () => { + const harness = createProvider({ ptyIdMintEpoch: 'current' }) + const spawn = () => + spawnForStablePane({ + runtime: undefined, + provider: harness.provider, + spawnOptions: agentSpawnOptions(), + owner: owner('pty2:current:1'), + connectionId: 'remote' + }) + + await Promise.all([spawn(), spawn()]) + expect(harness.requestHostRpc).toHaveBeenCalledOnce() + }) + + it('does not label a plain replacement shell as an unavailable agent resume', async () => { + const { freshOptions, result } = await runSpawn( + 'pty2:previous:1', + { ptyIdMintEpoch: 'current' }, + { cols: 80, rows: 24 } + ) + + expect(freshOptions).toEqual({ cols: 80, rows: 24 }) + expect(result.agentResumeUnavailable).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 9442e914e1f..db6a71aa0c9 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -13,6 +13,7 @@ import { import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isPtyAlreadyGoneError } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' +import { deriveStablePaneFreshSpawnOptions } from './relay-pty-mint-epoch' export type StablePaneOwner = { handle?: string @@ -287,7 +288,18 @@ export async function spawnForStablePane( return attached } } - const result = await args.provider.spawn(args.spawnOptions) + const freshSpawn = args.owner + ? await deriveStablePaneFreshSpawnOptions({ + provider: args.provider, + ownerPtyId: args.owner.ptyId, + connectionId: args.connectionId, + spawnOptions: args.spawnOptions + }) + : { options: args.spawnOptions, agentResumeDeclined: false } + const providerResult = await args.provider.spawn(freshSpawn.options) + const result = freshSpawn.agentResumeDeclined + ? { ...providerResult, agentResumeUnavailable: true as const } + : providerResult args.onFreshSpawn?.(result) return { result, owner: null } } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 0b4e8804ac0..bf0986586f2 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -38,6 +38,7 @@ import type { RuntimePtySpawnState } from './spawn-state' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args + const agentResumeDeclined = ctx.result.agentResumeUnavailable === true const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) try { ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ @@ -138,16 +139,18 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) + if (!agentResumeDeclined) { + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + } if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { const binding = { @@ -223,13 +226,13 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - if (!ctx.stablePaneOwner) { + if (!ctx.stablePaneOwner && !agentResumeDeclined) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !agentResumeDeclined) { markClaudePtySpawned(ctx.result.id) } - if (args.telemetry && !ctx.stablePaneOwner) { + if (args.telemetry && !ctx.stablePaneOwner && !agentResumeDeclined) { const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) @@ -267,7 +270,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) - if (!args.connectionId) { + if (!args.connectionId && !agentResumeDeclined) { ctx.deps.options?.onCodexHomePtySpawned?.({ id: ctx.result.id, codexHomePath: ctx.selectedCodexHomePath, @@ -286,8 +289,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { }) } const response = { - id: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...ctx.result, ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) ? { stablePaneOwner: { @@ -296,8 +298,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { leafId: ctx.stablePaneOwner.leafId } } - : {}), - ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}) + : {}) } resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { ...ctx.result, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index fcba7770a39..c30fdb7747f 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -101,6 +101,9 @@ export async function buildRuntimePtySpawnOptions( if (isTuiAgent(args.launchAgent)) { ctx.spawnOptions.launchAgent = args.launchAgent } + if (args.resumeProviderSession !== undefined) { + ctx.spawnOptions.resumeProviderSession = args.resumeProviderSession + } if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 2af050cd796..f93abf49fb0 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -23,13 +23,15 @@ function toRuntimeSpawnReply(result: { wslDistro?: string | null stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult + agentResumeUnavailable?: true }) { return { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) } } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 35fca5b0b34..96c58ee46fc 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -12,6 +12,7 @@ import type { import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' export type { PtyBackgroundStreamEvent, @@ -57,6 +58,8 @@ export type PtySpawnOptions = { startupCommandDelivery?: StartupCommandDelivery /** Minimal allowlisted launch ownership preserved by daemon reattach. */ launchAgent?: TuiAgent + /** Main-only resume intent retained until stable-pane recovery chooses the answering relay. */ + resumeProviderSession?: AgentProviderSessionMetadata /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 43e9665de45..6dbcddc1972 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -7,6 +7,8 @@ import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult + /** Main declined an agent resume owned by a different relay incarnation. */ + agentResumeUnavailable?: true /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ id: string diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index ec8a3e78941..f77fb8822df 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -203,6 +203,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) } + const agentResumeDeclined = result.agentResumeUnavailable === true this.registerPty(result.id, workspace.id, workspace.connectionId, { tabId, leafId, @@ -228,12 +229,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate pty.title = null pty.titleUpdatedAt = null } - pty.launchConfig = effectiveLaunchConfig - ? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig) - : null - pty.launchToken = launchToken ?? null - pty.launchIncarnationId = launchToken ? pty.incarnationId : null - pty.launchAgent = launchOpts.launchAgent ?? null + pty.launchConfig = + !agentResumeDeclined && effectiveLaunchConfig + ? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig) + : null + pty.launchToken = agentResumeDeclined ? null : (launchToken ?? null) + pty.launchIncarnationId = !agentResumeDeclined && launchToken ? pty.incarnationId : null + pty.launchAgent = agentResumeDeclined ? null : (launchOpts.launchAgent ?? null) } pty.tabId = tabId pty.paneKey = paneKey @@ -258,9 +260,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ptyId: result.id, title: launchOpts.title ?? null, ...(cwd !== workspace.path ? { cwd } : {}), - ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(!agentResumeDeclined && effectiveLaunchConfig + ? { launchConfig: effectiveLaunchConfig } + : {}), + ...(!agentResumeDeclined && launchToken ? { launchToken } : {}), + ...(!agentResumeDeclined && launchOpts.launchAgent + ? { launchAgent: launchOpts.launchAgent } + : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), @@ -289,6 +295,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), + ...(agentResumeDeclined ? { agentResumeUnavailable: true as const } : {}), ...(adoptedStablePane ? { isReattach: true as const } : {}), ...(warning ? { warning } : {}) } diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 2b17787f67d..35b92436d7c 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -85,6 +85,7 @@ export type RuntimePtyController = { wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult + agentResumeUnavailable?: true }> write(ptyId: string, data: string): boolean writeAgentSessionProof?( diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 82f0b19b9ff..4587bfb529d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -501,6 +501,10 @@ export class PtyHandler { this.dispatcher.onLegacyPtyCapacity?.(() => this.handleLegacyCapacity()) ?? null } + get mintEpoch(): string { + return this.ptyIdMintEpoch + } + setConsumerDeliveryPaused(id: string, paused: boolean): void { if (paused) { this.consumerPausedOutputPtys.add(id) diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index 3200dbb0ddb..bbf90826fea 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -138,6 +138,7 @@ function registerRelayStatus( ): void { primaryChannel.dispatcher.onRequest('relay.status', async () => ({ capabilities: SKILL_RELAY_CAPABILITIES, + ptyIdMintEpoch: runtime.ptyHandler.mintEpoch, pid: process.pid, uptimeMs: Date.now() - startedAt, detached: options.detached, diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 0c7eea10765..6fbd96b57af 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -770,11 +770,13 @@ describe('Subprocess: Relay entry point', () => { expect(resp.error).toBeUndefined() const status = resp.result as { pid: number + ptyIdMintEpoch?: string memory: { rss: number } ptys: { active: number } socket: { owned: boolean; listening: boolean; clients: number } } expect(status.pid).toBeGreaterThan(0) + expect(status.ptyIdMintEpoch).toEqual(expect.any(String)) expect(status.memory.rss).toBeGreaterThan(0) expect(status.ptys.active).toBe(0) expect(status.socket).toMatchObject({ owned: true, listening: true, clients: 0 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index 35b37855238..b98816e0cea 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -457,6 +457,9 @@ describe('connectPanePty', () => { expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.clearPaneForegroundAgent).toHaveBeenCalledWith(paneKey) + expect(transport.sendInput).not.toHaveBeenCalled() }) it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index cb0806900af..15c3ecce580 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -19,6 +19,7 @@ import { resolveSshReconnectModelPaint } from './resolve-ssh-reconnect-model-pai import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' +import { cancelDeclinedAgentResume } from './declined-agent-resume' export function createReattachPayloadHandlers( session: ReattachPayloadSession, @@ -279,6 +280,7 @@ export function createReattachPayloadHandlers( const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) if (didPrepareResume) { if (ctx.connectResult.agentResumeUnavailable) { + cancelDeclinedAgentResume(session) // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. session.showSessionRestoredBanner('resume-unavailable') @@ -297,7 +299,11 @@ export function createReattachPayloadHandlers( if (!isRemoteRuntimePtyId(ctx.ptyId)) { window.api.pty.ackColdRestore(ctx.ptyId) } - if (didPrepareResume && !ctx.coldRestoreStartup) { + if ( + didPrepareResume && + !ctx.coldRestoreStartup && + !ctx.connectResult.agentResumeUnavailable + ) { session.schedulePendingStartupCommandDelivery() } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts index c6a25e0d461..9691ae4f241 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -7,6 +7,11 @@ export type ConnectPanePtySession = { pane: ManagedPane manager: PaneManager deps: PtyConnectionDeps + cacheKey: string + pendingStartupCommand: { command: string } | null + startupInjectTimer: ReturnType | null + cancelStartupDraftPasteDelivery: () => void + clearRegisteredStartupLaunchConfig: () => void // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract [key: string]: any } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 2b55854c676..21537e39d73 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -114,6 +114,7 @@ export function connectPanePty( session.cleanupHiddenOutputRestoreFloodRepaint = (): void => {} session.resetRendererOrderedSeqForPtyExit = () => {} session.cleanupStartupDraftPasteTimers = (): void => {} + session.cancelStartupDraftPasteDelivery = (): void => {} session.unregisterE2ePtyDataInjection = (): void => {} session.startupInjectTimer = null session.agentTaskCompleteNotificationGraceTimer = null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/declined-agent-resume.ts b/src/renderer/src/components/terminal-pane/pty-connection/declined-agent-resume.ts new file mode 100644 index 00000000000..817525023cc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/declined-agent-resume.ts @@ -0,0 +1,22 @@ +import { useAppStore } from '@/store' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +type DeclinedAgentResumeSession = Pick< + ConnectPanePtySession, + | 'cacheKey' + | 'cancelStartupDraftPasteDelivery' + | 'clearRegisteredStartupLaunchConfig' + | 'pendingStartupCommand' + | 'startupInjectTimer' +> + +export function cancelDeclinedAgentResume(session: DeclinedAgentResumeSession): void { + session.pendingStartupCommand = null + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + session.startupInjectTimer = null + } + session.cancelStartupDraftPasteDelivery() + session.clearRegisteredStartupLaunchConfig() + useAppStore.getState().clearPaneForegroundAgent(session.cacheKey) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 2e0ebc74cb9..088635c1422 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -15,6 +15,7 @@ import type { import type { ConnectPanePtySession } from './connect-pane-pty-session' import { resolveTerminalTabId } from './terminal-tab-id' +import { cancelDeclinedAgentResume } from './declined-agent-resume' export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.startFreshSpawn = ( @@ -209,7 +210,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { + const agentResumeUnavailable = Boolean( + spawnedPtyId && + typeof spawnedPtyId === 'object' && + 'id' in spawnedPtyId && + spawnedPtyId.agentResumeUnavailable + ) + if (agentResumeUnavailable) { + cancelDeclinedAgentResume(session) + } else if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) @@ -225,11 +234,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) }) } - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.agentResumeUnavailable - ) { + if (spawnedPtyId && typeof spawnedPtyId === 'object' && agentResumeUnavailable) { // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. session.showSessionRestoredBanner('resume-unavailable') @@ -281,7 +286,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) } } - if (resolvedPtyId && session.connectionId) { + if (resolvedPtyId && session.connectionId && !agentResumeUnavailable) { if ( session.shouldUseProviderSshStartupDelivery && (startupOverride?.command || session.paneStartup?.command) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 9ca1202e9b1..4142d244907 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -88,6 +88,11 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { } } session.cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers + session.cancelStartupDraftPasteDelivery = (): void => { + startupDraftPasteSettled = true + clearStartupDraftPasteTimers() + session.releaseUnattemptedStartupDraftPasteDelivery() + } const getStartupDraftPtyId = (): string | null => { const ptyId = session.transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index 6bd4d78886d..bbec4e8d30c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -5,12 +5,16 @@ export type ReattachPayloadSession = Pick< | 'applyColdRestoreAgentResumeStartup' | 'applySnapshotKittyKeyboardModes' | 'buildColdRestoreAgentResumeStartup' + | 'cacheKey' + | 'cancelStartupDraftPasteDelivery' + | 'clearRegisteredStartupLaunchConfig' | 'clearSleepingRecordAfterColdRestoreSpawn' | 'consumeRestoredViewportBlankingMarker' | 'createReattachGridPush' | 'isPaneOnAlternateScreen' | 'kittyKeyboardModes' | 'pane' + | 'pendingStartupCommand' | 'pendingReattachFit' | 'reattachReplayResetSequence' | 'recordRendererOrderedSeq' @@ -21,6 +25,7 @@ export type ReattachPayloadSession = Pick< | 'shouldPreserveAgentReattachModes' | 'showSessionRestoredBanner' | 'suppressStructuralReplayPtyResize' + | 'startupInjectTimer' | 'transport' | 'writeFreshShellViewportBlanking' | 'writeReplayData' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 62c3ebee134..bd59ba4ee92 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2289,7 +2289,10 @@ export function createRemoteRuntimePtyTransport( return { id: remotePtyId, replay: '', - ...(createdTerminal.isReattach === true ? { isReattach: true } : {}) + ...(createdTerminal.isReattach === true ? { isReattach: true } : {}), + ...(createdTerminal.agentResumeUnavailable + ? { agentResumeUnavailable: true as const } + : {}) } satisfies PtyConnectResult } catch (error) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 16a16acf613..099a9c36ab9 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -260,6 +260,7 @@ export type RuntimeTerminalCreate = { surface?: 'background' | 'visible' warning?: string agentSessionDisposition?: 'created' | 'adopted' + agentResumeUnavailable?: true isReattach?: true /** Spawn process identity for host-internal ownership proof. */ processId?: number