From 9613563f5c9a2565753fa343427f98b81fc8a7df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:03:07 -0700 Subject: [PATCH] refactor(pty): one commit step for both terminal start lanes (#31596) * refactor(pty): one commit step for both terminal start lanes The window's pty:spawn commit and the host runtime controller's commit were two copies of the same job (ownership, binding save, registration, size, hidden mark, publication, reservation settle), so every fix had to land twice. Both lanes now call one commitPtySpawn and supply what differs as data: the binding each lane builds, its registration facts, pane key and aliases, the Codex launch env and memory session id. Three inputs still select behavior: hiddenDelivery (the renderer-scoped mark is refreshed here; the runtime-owned mark stays at the host runner's await boundary), earlyExit (the window drains an exit during start, the host throws) and joinPayload (what reservation joiners and the caller receive). One publication order for both lanes: note commit, Codex pane account, renderer hidden mark, handle, size set with reflow, old-id size cleanup, serializer readiness, restore records, command detector, telemetry, pane key and aliases, memory registry, resume identity, inventory, Codex lifecycle, settle. Every moved step was audited for read/write independence. Stable-owner adoption and the adopted-claim publication set are unchanged. Also pins that an agent launch whose record cannot open takes its tab back and spawns nothing, and re-labels three TEMPORARY comments to their current owners. * fix(pty): preserve adopted claim return scheduling * fix(pty): keep shared commit reply types precise --- config/main-layout-writer-baseline.txt | 3 +- .../ipc/pty/delivery/attached-pty-size.ts | 4 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 135 ------- .../pty/ipc/spawn-commit-run-facts.test.ts | 34 +- src/main/ipc/pty/ipc/spawn-commit.ts | 297 ++++---------- src/main/ipc/pty/ipc/spawn-options.ts | 2 +- src/main/ipc/pty/pane/key-state.ts | 10 +- src/main/ipc/pty/pane/stable-owner.ts | 2 +- .../ipc/pty/runtime/spawn-commit-pty-size.ts | 18 - .../ipc/pty/runtime/spawn-commit-response.ts | 49 ++- .../runtime/spawn-commit-run-facts.test.ts | 29 ++ src/main/ipc/pty/runtime/spawn-commit.ts | 377 ++++-------------- src/main/ipc/pty/spawn-commit-assembly.ts | 285 +++++++++++++ src/main/ipc/pty/spawn-commit-binding.ts | 97 +++++ src/main/ipc/pty/spawn-commit-publication.ts | 192 +++++++++ src/main/ipc/pty/spawn-host-env.ts | 2 +- src/main/ipc/pty/spawn-options-assembly.ts | 2 +- .../methods/agent-launch-instant-tab.test.ts | 18 + 18 files changed, 851 insertions(+), 705 deletions(-) delete mode 100644 src/main/ipc/pty/ipc/spawn-commit-persist.ts delete mode 100644 src/main/ipc/pty/runtime/spawn-commit-pty-size.ts create mode 100644 src/main/ipc/pty/spawn-commit-assembly.ts create mode 100644 src/main/ipc/pty/spawn-commit-binding.ts create mode 100644 src/main/ipc/pty/spawn-commit-publication.ts diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index 4166afdb39d..ea882afe8c7 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -3,10 +3,9 @@ # This is a RATCHET: counts may only go DOWN. Layout moves to one runtime owner; do not add # writers. Prune after removing writers: pnpm check:layout-writer-ratchets --prune 1 src/main/ipc/pty/ipc/leaf-move.ts -1 src/main/ipc/pty/ipc/spawn-commit-persist.ts 1 src/main/ipc/pty/pane/pane-owner-replacement.ts 3 src/main/ipc/pty/pane/stable-owner.ts -1 src/main/ipc/pty/runtime/spawn-commit.ts +2 src/main/ipc/pty/spawn-commit-binding.ts 1 src/main/ipc/renderer-shutdown-checkpoint.ts 1 src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts 1 src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts diff --git a/src/main/ipc/pty/delivery/attached-pty-size.ts b/src/main/ipc/pty/delivery/attached-pty-size.ts index db236557066..0ff548a6107 100644 --- a/src/main/ipc/pty/delivery/attached-pty-size.ts +++ b/src/main/ipc/pty/delivery/attached-pty-size.ts @@ -63,8 +63,8 @@ export function reflowHeadlessTerminalToCommittedGrid(args: { ) } -/** Record the settled grid, then reflow the model onto it. Callers that seed the model between the - * two steps (ipc spawn commit) call the halves separately. */ +/** Record the settled grid, then reflow the model onto it. Spawn commits call it only after + * registration admits the incarnation; the model was seeded before their first await. */ export function commitAttachedPtySize(args: { result: Pick< PtySpawnResult, diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts deleted file mode 100644 index 09c17fd391f..00000000000 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { closeStartupQueryAuthorityForPty } from '../provider/registry' -import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' -import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' -import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement' -import { PaneOwnerContentionError } from '../pane/pane-spawn-operation' -import { persistFreshPaneBinding } from '../runtime/spawn-commit' -import { - pendingByPaneKey, - pendingPtyIdBySerializerGeneration, - rendererSerializerReadiness -} from '../pane/serializer-state' -import { ptySizes } from '../delivery/visibility-state' -import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' -import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' -import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' -import type { PtyIpcSpawnState } from './spawn-state' -import { startupCwdFallbackOutcome } from '../spawn-startup-target' -import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' - -export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { - const args = ctx.args - const startupCwd = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) - ? (ctx.correctStartupCwd ?? ctx.requestedCwd) - : ctx.requestedCwd - - const committedSize = resolveCommittedPtySize({ - result: ctx.result, - requested: { cols: args.cols, rows: args.rows }, - cachedBeforeAttach: ctx.sessionSizeBeforeAttach - }) - // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. - if ( - ctx.deps.store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - ctx.validatedLeafId !== null - ) { - try { - const placement = parseTerminalPanePlacement(args.placement) - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: ctx.validatedLeafId, - ptyId: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(startupCwd ? { startupCwd } : {}), - ...(placement ? { placement } : {}), - ...(ctx.paneSpawnOperation?.mustExist ? { mayCreate: false } : {}), - origin: spawnCommitBindingOrigin(ctx.result) - } - const persisted = ctx.replacedPaneOwner - ? await ctx.deps.store.persistPtyBinding( - swapReplacedPaneBinding(ctx.deps.store, binding, ctx.replacedPaneOwner) - ) - : await persistFreshPaneBinding( - ctx.deps.store, - binding, - args.connectionId, - ctx.paneSpawnOperation?.absentOwner - ) - if (persisted === false) { - throw new Error('terminal_pane_owner_changed') - } - // The swap holds the live binding now: a later failure must not retire it under the same id. - ctx.replacedPaneOwner = null - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - await discardUnpersistedPtySpawn(ctx.provider, ctx.result, ctx.deps.runtime) - if ( - err instanceof PaneOwnerContentionError || - (err instanceof Error && err.message === 'terminal_pane_owner_changed') - ) { - throw err - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(err)), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - return committedSize -} - -export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void { - // Why here: every IPC spawn that survives its binding save publishes once through this point. - ctx.deps.runtime?.noteTerminalSpawnCommit?.(ctx.result) - ctx.spawnTiming.log(ctx.result.id, { - 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, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) - if (ctx.initiallyHidden) { - // Refresh the pre-spawn hidden mark only after this incarnation survives its save. - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) - if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { - // Defense: never strand a mark on an id the provider renamed. - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) - } - // Why after ownership (recorded at commit start): provider lookup routes by it, and a hidden-spawned agent should be paceable from its first flood. - ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') - closeStartupQueryAuthorityForPty(ctx.result.id) - } - if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { - if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { - ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) - ctx.agentTeamsLeaderHandle = null - } - } - ptySizes.set(ctx.result.id, committedSize) - if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { - ptySizes.delete(ctx.effectiveSessionAppId) - } - const rendererPreSignaled = ctx.validatedPaneKey - ? pendingByPaneKey.has(ctx.validatedPaneKey) - : false - const rendererAlreadyRegistered = - ctx.result.isReattach === true && - !rendererPreSignaled && - rendererSerializerReadiness.has(ctx.result.id) - rendererSerializerReadiness.beginIncarnation(ctx.result.id, rendererAlreadyRegistered) - // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. - if (ctx.validatedPaneKey && rendererPreSignaled) { - const pending = pendingByPaneKey.get(ctx.validatedPaneKey) - if (pending) { - pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) - } - } -} diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index c0117c0fc86..45cc8106618 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -7,11 +7,21 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcDeps } from './spawn-types' +import { + claimRuntimePaneCreate, + pendingRuntimePaneCreatesByOwnerKey, + reservePaneSpawn +} from '../pane/spawn-reservation' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) const PTY_ID = 'orca-ipc-pty-run-facts' const INCARNATION_ID = 'inc-ipc-run-facts' -async function commit(persistPtyBinding: ReturnType) { +async function commit( + persistPtyBinding: ReturnType, + prepare?: (ctx: ReturnType) => void +) { const facts = new TerminalRunFactsRegister() const stops = new TerminalIntentionalStops() stops.mark(PTY_ID, 'reversible', null)(true) @@ -45,6 +55,7 @@ async function commit(persistPtyBinding: ReturnType) { ctx.validatedLeafId = 'leaf-1' ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + prepare?.(ctx) const outcome = await commitPtyIpcSpawn(ctx).then( () => 'committed', () => 'discarded' @@ -76,4 +87,25 @@ describe('renderer spawn commit: run facts', () => { expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['replaced']) }) + + it('gives a runtime-create waiter the window result before the reservation waiter resumes', async () => { + const key = 'window-commit-runtime-waiter' + const reservation = reservePaneSpawn(key) + const release = claimRuntimePaneCreate(key) + const pending = pendingRuntimePaneCreatesByOwnerKey.get(key)! + const joined = reservation.promise.then(() => { + release() + return pending.promise + }) + try { + const { outcome } = await commit(vi.fn().mockResolvedValue(true), (ctx) => { + ctx.paneSpawnReservationKey = key + ctx.paneSpawnReservation = reservation + }) + expect(outcome).toBe('committed') + await expect(joined).resolves.toMatchObject({ id: PTY_ID, incarnationId: INCARNATION_ID }) + } finally { + release() + } + }) }) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index cf4572c6c25..10909fed91f 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,205 +1,105 @@ -import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { agentHookServer } from '../../../agent-hooks/server' -import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' -import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' -import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' -import { - shouldSkipCodexHomeEnvForWindowsShell, - codexReattachedHomeRouteField -} from '../host-env/codex-home' -import { rememberPaneKeyForPty } from '../pane/key-state' -import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' -import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity' -import { - admitProviderReattachLaunchIdentity, - admitRendererAgentLaunchAuthority -} from '../pane/launch-authority' +import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { startupCwdFallbackOutcome } from '../spawn-startup-target' -import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' -import { admitPtySpawnOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration' -import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' -import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' -import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtySpawn } from '../spawn-commit-assembly' +import type { PtySpawnPaneBinding } from '../spawn-commit-binding' +import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' +import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { - return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) -} - -async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { - const args = ctx.args - admitPtySpawnOwnership(ctx.deps.runtime, ctx.result) - if (ctx.nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(ctx.result.id) - } - // Seed before the first disk await so live output appends to the restored history. - seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey) - seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) - const committedSize = await persistPtyIpcSpawnCommit(ctx) - if ( - typeof args.worktreeId === 'string' && - 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 providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({ - isReattach: ctx.result.isReattach === true, - launchAgent: ctx.result.launchAgent, - incarnationId: ctx.result.incarnationId - }) - const rejectedRegistration = registerPersistedPtySpawn( - ctx.deps.runtime, - ctx.deps.store, - ctx.pendingRegistrationClaim, - ctx.result.id, - args.worktreeId, - null, - // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - ctx.metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: ctx.metadataLeafId, - ...(ctx.preAllocatedHandle ? { terminalHandle: ctx.preAllocatedHandle } : {}), - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}), - ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) - } - : undefined, - shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) - ) - if (rejectedRegistration) { - await rejectedRegistration.catch((error: unknown) => { - if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { - throw error - } - }) - ctx.deps.runtime?.cancelPendingPtyRegistration?.( - ctx.result.id, - ctx.result.incarnationId, - ctx.pendingRegistrationClaim - ) - ctx.pendingRegistrationPtyId = null - ctx.exitedBeforeRegistration = true - // The renderer drains this incarnation's buffered output and exit without publishing it live. - return resolvePaneSpawnReservation( - ctx.paneSpawnReservationKey, - ctx.paneSpawnReservation, - ctx.result - ) - } - ctx.pendingRegistrationPtyId = null - } else if (ctx.pendingRegistrationPtyId) { - ctx.deps.runtime?.cancelPendingPtyRegistration?.( - ctx.pendingRegistrationPtyId, - ctx.result.incarnationId, - ctx.pendingRegistrationClaim - ) - ctx.pendingRegistrationPtyId = null - } - publishPtyIpcSpawnCommit(ctx, committedSize) - - // Admission must precede reflow: a replaced spawn cannot resize its successor's model. - reflowHeadlessTerminalToCommittedGrid({ - result: ctx.result, - committedSize, - reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind( - ctx.deps.runtime - ) - }) - // Why: seed after registerPty binds the worktree — including on - // desktop, where the renderer-authority gate above skips the emulator - // 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) { - ctx.deps.runtime?.noteTerminalSpawnCommand?.( - ctx.result.id, - typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null - ) - } - // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. - // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. - const rememberedPaneKey = ctx.validatedPaneKey - ? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey) - : null - if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) { - agentHookServer.registerPaneKeyAlias( - ctx.legacySpawnPaneKey.paneKey, - ctx.migrationUnsupportedPaneKey, - ctx.result.id, - Date.now(), - { authorityVerified: true } - ) - clearMigrationUnsupportedPtysForPaneKey(ctx.migrationUnsupportedPaneKey) - } else if (ctx.opaqueRemintedSpawnPaneKey && ctx.validatedPaneKey) { - // Reminted $$ tokens are spawn-physical; alias them to the metadata-proven tab:leaf key. - agentHookServer.registerPaneKeyAlias( - ctx.opaqueRemintedSpawnPaneKey, - ctx.validatedPaneKey, - ctx.result.id, - Date.now(), - { authorityVerified: true } - ) - } else if (ctx.validatedPaneKey) { - if (!ctx.result.isReattach) { - clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey) - } - } - // Why after commit: a spawn rejected above must not leave a published identity behind. - seedResumeLaunchIdentityFromSpawn({ - paneKey: ctx.validatedPaneKey, - launchAgent: args.launchAgent, - resumeProviderSession: args.resumeProviderSession, - connectionId: args.connectionId, - worktreeId: args.worktreeId, - launchedAt: ctx.codexHomeLaunchStartedAt, - resumeArgvDropped: ctx.codexResumeLaunch.droppedResumeArgv, - result: ctx.result - }) - // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). - const spawnedPid = ctx.result.pid ?? null - // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. - registerPty({ - ptyId: ctx.result.id, - worktreeId: + return commitPtyWithOpenCodePromptIntent(ctx, () => { + const args = ctx.args + // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. + const worktreeId = typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && args.worktreeId.length <= 512 ? args.worktreeId - : null, - sessionId: - typeof args.sessionId === 'string' && - args.sessionId.length > 0 && - args.sessionId.length <= 256 - ? args.sessionId - : null, - paneKey: rememberedPaneKey, - pid: - typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 - ? spawnedPid : null + const response = ipcSpawnResponse(ctx) + return commitPtySpawn({ + state: ctx, + nativeWindowsConptySpawn: ctx.nativeWindowsConptySpawn, + seedPaneKey: ctx.validatedPaneKey, + paneBinding: ipcSpawnPaneBinding(ctx), + registration: { + store: ctx.deps.store, + worktreeId, + shellOverride: ctx.effectiveShellOverride, + rendererLaunchAuthority: 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 + }) + }, + preAllocatedHandle: ctx.preAllocatedHandle, + spawnTiming: ctx.spawnTiming, + paneKey: ctx.validatedPaneKey, + paneKeyAliases: { + legacySpawnPaneKey: ctx.legacySpawnPaneKey, + migrationUnsupportedPaneKey: ctx.migrationUnsupportedPaneKey, + opaqueRemintedSpawnPaneKey: ctx.opaqueRemintedSpawnPaneKey + }, + memorySessionId: + typeof args.sessionId === 'string' && + args.sessionId.length > 0 && + args.sessionId.length <= 256 + ? args.sessionId + : null, + resumeArgvDropped: ctx.codexResumeLaunch.droppedResumeArgv, + codexLaunchEnv: ctx.baseEnv, + initiallyHidden: ctx.initiallyHidden, + hiddenDelivery: 'renderer-scoped', + earlyExit: 'drain', + joinPayload: { reservation: response, reply: response } + }) }) - // 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) { - recordPtySpawnTelemetry(args.telemetry) +} + +function ipcSpawnPaneBinding(ctx: PtyIpcSpawnState): PtySpawnPaneBinding | undefined { + const args = ctx.args + // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. + if ( + !ctx.deps.store || + typeof args.worktreeId !== 'string' || + typeof args.tabId !== 'string' || + ctx.validatedLeafId === null + ) { + return undefined } + const startupCwd = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) + ? (ctx.correctStartupCwd ?? ctx.requestedCwd) + : ctx.requestedCwd + const placement = parseTerminalPanePlacement(args.placement) + return { + store: ctx.deps.store, + binding: { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(startupCwd ? { startupCwd } : {}), + ...(placement ? { placement } : {}), + ...(ctx.paneSpawnOperation?.mustExist ? { mayCreate: false } : {}), + origin: spawnCommitBindingOrigin(ctx.result) + }, + absentOwner: ctx.paneSpawnOperation?.absentOwner, + refusal: 'terminal_pane_owner_changed', + failureLog: '[pty] failed to persist PTY binding after spawn:' + } +} + +function ipcSpawnResponse(ctx: PtyIpcSpawnState) { const { startupCwdSource: _startupCwdSource, ...providerResult } = ctx.result const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result) - const response = { + return { ...providerResult, // Why both or neither: a pane can only adopt proven kitty flags together // with the sequence boundary they describe. @@ -218,27 +118,4 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise