fix(pty): gate resumes by relay epoch

This commit is contained in:
Merge Sim
2026-09-01 12:54:19 -07:00
parent 79990464d2
commit d57a480caa
26 changed files with 428 additions and 64 deletions
+12 -10
View File
@@ -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)
+19 -16
View File
@@ -27,6 +27,7 @@ import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
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<PtySpawn
args.worktreeId.length > 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<PtySpawn
// seed but the list/read records still live main-side.
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
if (!ctx.stablePaneOwner) {
if (!ctx.stablePaneOwner && !agentResumeDeclined) {
ctx.deps.runtime?.noteTerminalSpawnCommand?.(
ctx.result.id,
typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null
)
}
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !agentResumeDeclined) {
markClaudePtySpawned(ctx.result.id)
}
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
@@ -139,7 +142,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
const rememberedPaneKey = ctx.validatedPaneKey
? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey)
: null
if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) {
if (!agentResumeDeclined && ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
ctx.legacySpawnPaneKey.paneKey,
ctx.migrationUnsupportedPaneKey,
@@ -157,7 +160,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
Date.now(),
{ authorityVerified: true }
)
} else if (ctx.validatedPaneKey) {
} else if (!agentResumeDeclined && ctx.validatedPaneKey) {
if (!ctx.result.isReattach) {
clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey)
}
@@ -189,7 +192,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
})
}
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
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)
@@ -211,7 +214,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
ctx.snapshotKittyFlagsCoverReconciledSeq
? { snapshotSeq: ctx.reconciledSnapshotSeq }
: { snapshotKittyKeyboardFlags: undefined }),
...(!ctx.result.isReattach && ctx.effectiveLaunchConfig
...(!ctx.result.isReattach && !agentResumeDeclined && ctx.effectiveLaunchConfig
? { launchConfig: ctx.effectiveLaunchConfig }
: {}),
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
@@ -226,7 +229,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
if (!args.connectionId) {
if (!args.connectionId && !agentResumeDeclined) {
ctx.deps.options?.onCodexHomePtySpawned?.({
id: ctx.result.id,
codexHomePath: ctx.selectedCodexHomePath,
+3
View File
@@ -77,6 +77,9 @@ export async function buildPtyIpcSpawnOptions(
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
}
@@ -0,0 +1,98 @@
import type { IPtyProvider, PtySpawnOptions } from '../../../providers/types'
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
const RELAY_STATUS_TIMEOUT_MS = 2_000
const relayMintEpochByProvider = new WeakMap<IPtyProvider, Promise<string | undefined>>()
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<string | undefined> {
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
}
}
@@ -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()
})
})
+13 -1
View File
@@ -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 }
}
+19 -18
View File
@@ -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,
@@ -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
}
+3 -1
View File
@@ -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 } : {})
}
}
@@ -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
+2
View File
@@ -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
@@ -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 } : {})
}
@@ -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?(
+4
View File
@@ -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)
+1
View File
@@ -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,
+2
View File
@@ -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 })
@@ -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 () => {
@@ -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()
}
}
@@ -7,6 +7,11 @@ export type ConnectPanePtySession = {
pane: ManagedPane
manager: PaneManager
deps: PtyConnectionDeps
cacheKey: string
pendingStartupCommand: { command: string } | null
startupInjectTimer: ReturnType<typeof setTimeout> | null
cancelStartupDraftPasteDelivery: () => void
clearRegisteredStartupLaunchConfig: () => void
// oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract
[key: string]: any
}
@@ -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
@@ -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)
}
@@ -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)
@@ -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 (
@@ -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'
@@ -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) {
+1
View File
@@ -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