mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PtyGrid> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.fn>) {
|
||||
async function commit(
|
||||
persistPtyBinding: ReturnType<typeof vi.fn>,
|
||||
prepare?: (ctx: ReturnType<typeof createPtyIpcSpawnState>) => 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<typeof vi.fn>) {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PtySpawnResult> {
|
||||
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
|
||||
}
|
||||
|
||||
async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
|
||||
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<PtySpawnResult>({
|
||||
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<PtySpaw
|
||||
? { agentResumeUnavailable: true as const }
|
||||
: {})
|
||||
}
|
||||
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
||||
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
|
||||
ctx.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: ctx.result.id,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
startedAt: ctx.codexHomeLaunchStartedAt,
|
||||
startedSequence: ctx.codexHomeLaunchStartedSequence,
|
||||
...codexReattachedHomeRouteField(
|
||||
ctx.reattachedCodexHomeRoutes,
|
||||
ctx.result.id,
|
||||
ctx.result.isReattach === true
|
||||
),
|
||||
...(ctx.result.isReattach === true
|
||||
? { reattached: true }
|
||||
: ctx.baseEnv
|
||||
? { launchEnv: ctx.baseEnv }
|
||||
: {})
|
||||
})
|
||||
return resolvePaneSpawnReservation(
|
||||
ctx.paneSpawnReservationKey,
|
||||
ctx.paneSpawnReservation,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function buildPtyIpcSpawnOptions(ctx: PtyIpcSpawnState): Promise<vo
|
||||
ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId)
|
||||
ctx.initiallyHidden = args.initiallyHidden === true
|
||||
// Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below.
|
||||
// TEMPORARY (C5e): absent-owner cleanup clears the early mark before replacement spawn.
|
||||
// TEMPORARY (attachment-derived delivery lane): a renderer reset may clear the early mark after preflight.
|
||||
ctx.preSpawnHiddenMarkId =
|
||||
ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined
|
||||
? ctx.effectiveSessionAppId
|
||||
|
||||
@@ -29,9 +29,15 @@ export function isValidPaneKey(paneKey: unknown): paneKey is string {
|
||||
return parseValidPaneKey(paneKey) !== null
|
||||
}
|
||||
|
||||
export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
|
||||
/** The key `rememberPaneKeyForPty` would record, without recording it. */
|
||||
export function normalizeValidPaneKey(paneKey: unknown): string | null {
|
||||
const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : ''
|
||||
if (!isValidPaneKey(normalizedPaneKey)) {
|
||||
return isValidPaneKey(normalizedPaneKey) ? normalizedPaneKey : null
|
||||
}
|
||||
|
||||
export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
|
||||
const normalizedPaneKey = normalizeValidPaneKey(paneKey)
|
||||
if (normalizedPaneKey === null) {
|
||||
return null
|
||||
}
|
||||
// Why: a re-registered ptyId would otherwise leave its old paneKey pointing here.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { persistFreshPaneBinding } from '../runtime/spawn-commit'
|
||||
import { persistFreshPaneBinding } from '../spawn-commit-binding'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../../../persistence/restoring-sessions/workspace-session-write-rollback'
|
||||
import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { commitAttachedPtySize } from '../delivery/attached-pty-size'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
|
||||
/** Record the settled grid for a runtime-path spawn; `result` is passed explicitly because the
|
||||
* adopted-claim branch commits before it returns early. */
|
||||
export function commitRuntimePtySize(
|
||||
ctx: RuntimePtySpawnState,
|
||||
result: RuntimePtySpawnState['result']
|
||||
): void {
|
||||
commitAttachedPtySize({
|
||||
result,
|
||||
requested: { cols: ctx.args.cols, rows: ctx.args.rows },
|
||||
cachedBeforeAttach: ctx.sessionSizeBeforeAttach,
|
||||
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
|
||||
ctx.deps.runtime
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { startupCwdFallbackOutcome } from '../spawn-startup-target'
|
||||
import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { noteRuntimePaneCreateResult, resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import type { PaneResumeOutcome } from '../../../../shared/workspace-layout/workspace-layout-published'
|
||||
|
||||
type RuntimeResumeOutcomeState = Pick<
|
||||
RuntimePtySpawnState,
|
||||
'args' | 'result' | 'codexResumeArgvDropped'
|
||||
> & { codexResumeUnavailable?: boolean }
|
||||
|
||||
/** Whether a fresh start resumed the agent it was asked to; a reattach ran no launch command. */
|
||||
export function resumeOutcomeOf(ctx: RuntimePtySpawnState): PaneResumeOutcome | null {
|
||||
export function resumeOutcomeOf(ctx: RuntimeResumeOutcomeState): PaneResumeOutcome | null {
|
||||
if (!ctx.args.resumeProviderSession || ctx.result.isReattach) {
|
||||
return null
|
||||
}
|
||||
@@ -20,7 +24,7 @@ export function resumeOutcomeOf(ctx: RuntimePtySpawnState): PaneResumeOutcome |
|
||||
* Switch point: tells the start whether it resumed the pane's agent before the pane's bind is
|
||||
* written, so the frame that first shows the new terminal carries the outcome too.
|
||||
*/
|
||||
export function reportRuntimeResumeOutcome(ctx: RuntimePtySpawnState): void {
|
||||
export function reportRuntimeResumeOutcome(ctx: RuntimeResumeOutcomeState): void {
|
||||
const outcome =
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN && ctx.args.onResumeOutcome ? resumeOutcomeOf(ctx) : null
|
||||
if (outcome) {
|
||||
@@ -28,11 +32,15 @@ export function reportRuntimeResumeOutcome(ctx: RuntimePtySpawnState): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function completeRuntimeSpawnResponse(ctx: RuntimePtySpawnState) {
|
||||
/** Joiners get the provider result at its proven kitty boundary as an attach; the caller gets the
|
||||
* saved folder and host fields too. */
|
||||
export function runtimeSpawnJoinPayload(
|
||||
ctx: RuntimePtySpawnState,
|
||||
startupCwdFallback: StartupCwdFallback | undefined,
|
||||
correctStartupCwd: string | undefined
|
||||
) {
|
||||
const args = ctx.args
|
||||
const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result)
|
||||
const savedStartupCwd =
|
||||
(startupCwdFallback ? ctx.correctStartupCwd : undefined) ?? ctx.requestedCwd
|
||||
const savedStartupCwd = correctStartupCwd ?? ctx.requestedCwd
|
||||
const response = {
|
||||
savedStartupCwd,
|
||||
id: ctx.result.id,
|
||||
@@ -49,19 +57,16 @@ export function completeRuntimeSpawnResponse(ctx: RuntimePtySpawnState) {
|
||||
...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}),
|
||||
...(startupCwdFallback ? { startupCwdFallback } : {})
|
||||
}
|
||||
const joined = {
|
||||
...ctx.result,
|
||||
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
|
||||
ctx.reconciledSnapshotSeq !== null &&
|
||||
ctx.snapshotKittyFlagsCoverReconciledSeq
|
||||
? { snapshotSeq: ctx.reconciledSnapshotSeq }
|
||||
: { snapshotKittyKeyboardFlags: undefined }),
|
||||
isReattach: true as const
|
||||
return {
|
||||
reservation: {
|
||||
...ctx.result,
|
||||
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
|
||||
ctx.reconciledSnapshotSeq !== null &&
|
||||
ctx.snapshotKittyFlagsCoverReconciledSeq
|
||||
? { snapshotSeq: ctx.reconciledSnapshotSeq }
|
||||
: { snapshotKittyKeyboardFlags: undefined }),
|
||||
isReattach: true
|
||||
},
|
||||
reply: { ...ctx.result, ...response, wslDistro: ctx.result.wslDistro ?? undefined }
|
||||
}
|
||||
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, joined)
|
||||
// Switch point: a window view waiting on this create attaches to what it started.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
noteRuntimePaneCreateResult(ctx.paneSpawnReservationKey, joined)
|
||||
}
|
||||
return { ...ctx.result, ...response, wslDistro: ctx.result.wslDistro ?? undefined }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { OrcaRuntimeWithRuntimeId } from '../../../runtime/construction/runtime-identity-state'
|
||||
import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops'
|
||||
import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts'
|
||||
@@ -98,6 +99,34 @@ describe('runtime spawn commit: run facts', () => {
|
||||
expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the resumed agent before the bind can publish its first frame', async () => {
|
||||
const onResumeOutcome = vi.fn()
|
||||
const persistPtyBinding = vi.fn(async () => {
|
||||
expect(onResumeOutcome.mock.calls).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : []
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
await commit({}, undefined, undefined, (ctx) => {
|
||||
ctx.args.resumeProviderSession = { key: 'session_id', id: 'codex-session-1' }
|
||||
ctx.args.onResumeOutcome = onResumeOutcome
|
||||
ctx.codexResumeUnavailable = true
|
||||
ctx.codexResumeArgvDropped = true
|
||||
ctx.hostSessionBinding = {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback.
|
||||
store: { persistPtyBinding } as unknown as NonNullable<
|
||||
typeof ctx.hostSessionBinding
|
||||
>['store'],
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(persistPtyBinding).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('records nothing for a spawn discarded because its binding save failed', async () => {
|
||||
const facts = new TerminalRunFactsRegister()
|
||||
const stops = new TerminalIntentionalStops()
|
||||
|
||||
@@ -1,320 +1,79 @@
|
||||
import { completeRuntimeSpawnResponse, reportRuntimeResumeOutcome } from './spawn-commit-response'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence'
|
||||
import { ptyBindingIsRefused } from '../../../persistence/loading-store/pty-binding-refusals'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
isAbsentBindingReplay,
|
||||
PaneOwnerContentionError,
|
||||
type AbsentPaneBinding
|
||||
} from '../pane/pane-spawn-operation'
|
||||
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
import { commitRuntimePtySize } from './spawn-commit-pty-size'
|
||||
import {
|
||||
shouldSkipCodexHomeEnvForWindowsShell,
|
||||
recordCodexPaneAccountForSpawn,
|
||||
codexReattachedHomeRouteField
|
||||
} from '../host-env/codex-home'
|
||||
import { registerPty } from '../../../memory/pty-registry'
|
||||
import { rememberPaneKeyForPty } from '../pane/key-state'
|
||||
import {
|
||||
pendingByPaneKey,
|
||||
pendingPtyIdBySerializerGeneration,
|
||||
rendererSerializerReadiness
|
||||
} from '../pane/serializer-state'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
|
||||
import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
|
||||
import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry'
|
||||
import {
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
markNativeWindowsConptyPty
|
||||
} from '../../../runtime/terminal-model-query-authority'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
||||
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
|
||||
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
|
||||
import { runtimeSpawnJoinPayload } from './spawn-commit-response'
|
||||
import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority'
|
||||
import { normalizeValidPaneKey } from '../pane/key-state'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { startupCwdFallbackOutcome } from '../spawn-startup-target'
|
||||
import { commitPtySpawn } from '../spawn-commit-assembly'
|
||||
import type { PtySpawnPaneBinding } from '../spawn-commit-binding'
|
||||
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
|
||||
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
|
||||
import {
|
||||
admitPtySpawnOwnership,
|
||||
discardUnpersistedPtySpawn,
|
||||
registerPersistedPtySpawn
|
||||
} from '../pane/spawn-registration'
|
||||
|
||||
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedRuntimePtySpawn(ctx))
|
||||
return commitPtyWithOpenCodePromptIntent(ctx, () => {
|
||||
const args = ctx.args
|
||||
const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result)
|
||||
// Fallback may follow an access error; only proven absence authorizes changing the saved folder.
|
||||
const correctStartupCwd = startupCwdFallback ? ctx.correctStartupCwd : undefined
|
||||
const ensure = ctx.result.agentSessionEnsure
|
||||
return commitPtySpawn({
|
||||
state: ctx,
|
||||
nativeWindowsConptySpawn: isNativeWindowsLocalPtySpawn({
|
||||
connectionId: args.connectionId,
|
||||
cwd: args.cwd,
|
||||
shellOverride: ctx.daemonShellOverride
|
||||
}),
|
||||
seedPaneKey: ctx.spawnIdentityPaneKey,
|
||||
paneBinding: runtimeSpawnPaneBinding(ctx, correctStartupCwd),
|
||||
expectedSourceBinding: ctx.hostSessionBinding?.expectedSourceBinding,
|
||||
adoptedAgentSession: ensure?.disposition === 'adopted' ? ensure : undefined,
|
||||
registration: {
|
||||
store: ctx.hostSessionBinding?.store ?? ctx.deps.store,
|
||||
worktreeId: args.worktreeId,
|
||||
shellOverride: ctx.daemonShellOverride,
|
||||
rendererLaunchAuthority: null
|
||||
},
|
||||
preAllocatedHandle: args.preAllocatedHandle,
|
||||
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; hook titles and cache cleanup need this reverse lookup.
|
||||
paneKey: normalizeValidPaneKey(ctx.env?.ORCA_PANE_KEY),
|
||||
memorySessionId: ctx.sessionId ?? null,
|
||||
resumeArgvDropped: ctx.codexResumeArgvDropped,
|
||||
codexLaunchEnv: ctx.env,
|
||||
initiallyHidden: args.initiallyHidden === true,
|
||||
hiddenDelivery: 'runtime-owned',
|
||||
earlyExit: 'throw',
|
||||
joinPayload: runtimeSpawnJoinPayload(ctx, startupCwdFallback, correctStartupCwd)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
const args = ctx.args
|
||||
admitPtySpawnOwnership(ctx.deps.runtime, ctx.result)
|
||||
const startupCwdFallback = startupCwdFallbackOutcome(ctx.startupCwdFallback, ctx.result)
|
||||
// Fallback may follow an access error; only proven absence authorizes changing the saved folder.
|
||||
const correctStartupCwd = startupCwdFallback ? ctx.correctStartupCwd : undefined
|
||||
function runtimeSpawnPaneBinding(
|
||||
ctx: RuntimePtySpawnState,
|
||||
correctStartupCwd: string | undefined
|
||||
): PtySpawnPaneBinding | undefined {
|
||||
if (!ctx.hostSessionBinding) {
|
||||
return undefined
|
||||
}
|
||||
const { store, worktreeId, tabId, leafId, expectedSourceBinding, placement } =
|
||||
ctx.hostSessionBinding
|
||||
const savedStartupCwd = correctStartupCwd ?? ctx.requestedCwd
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
|
||||
if (
|
||||
isNativeWindowsLocalPtySpawn({
|
||||
connectionId: args.connectionId,
|
||||
cwd: args.cwd,
|
||||
shellOverride: ctx.daemonShellOverride
|
||||
})
|
||||
) {
|
||||
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.spawnIdentityPaneKey)
|
||||
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
|
||||
|
||||
if (ctx.result.agentSessionEnsure?.disposition === 'adopted') {
|
||||
// Why: an adoption is an attach to a live owner by definition, but an adopted reply can omit
|
||||
// isReattach; derive it once so the size commit and the reservation agree.
|
||||
const adoptedResult = { ...ctx.result, isReattach: true }
|
||||
const owner = ctx.result.agentSessionEnsure.owner
|
||||
const rejectedRegistration = registerPersistedPtySpawn(
|
||||
ctx.deps.runtime,
|
||||
ctx.hostSessionBinding?.store ?? ctx.deps.store,
|
||||
ctx.pendingRegistrationClaim,
|
||||
ctx.result.id,
|
||||
owner.surface.worktreeId,
|
||||
null,
|
||||
{
|
||||
tabId: owner.surface.tabId,
|
||||
leafId: owner.surface.leafId,
|
||||
terminalHandle: owner.surface.terminalHandle,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
)
|
||||
if (rejectedRegistration) {
|
||||
await rejectedRegistration
|
||||
}
|
||||
// Why here: an adoption returns before the commit site below.
|
||||
ctx.deps.runtime?.noteTerminalSpawnCommit?.(
|
||||
ctx.result,
|
||||
ctx.hostSessionBinding?.expectedSourceBinding
|
||||
)
|
||||
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
|
||||
ctx.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: ctx.result.id,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
reattached: true,
|
||||
startedAt: ctx.codexHomeLaunchStartedAt,
|
||||
startedSequence: ctx.codexHomeLaunchStartedSequence,
|
||||
...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true),
|
||||
...(ctx.env ? { launchEnv: ctx.env } : {})
|
||||
})
|
||||
// Why: this branch returns before the normal commit site; without this the cache keeps
|
||||
// whatever the caller requested.
|
||||
commitRuntimePtySize(ctx, adoptedResult)
|
||||
// Why: the adopted branch returns before the normal settle site, so the
|
||||
// reservation must be resolved here or every later spawn for this pane
|
||||
// awaits a promise that never settles.
|
||||
resolvePaneSpawnReservation(
|
||||
ctx.paneSpawnReservationKey,
|
||||
ctx.paneSpawnReservation,
|
||||
adoptedResult
|
||||
)
|
||||
return {
|
||||
id: ctx.result.id,
|
||||
return {
|
||||
store,
|
||||
binding: {
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
ptyId: ctx.result.id,
|
||||
// The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile.
|
||||
mayCreate: false,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
agentSessionEnsure: ctx.result.agentSessionEnsure
|
||||
}
|
||||
...(savedStartupCwd ? { startupCwd: savedStartupCwd } : {}),
|
||||
...(correctStartupCwd ? { correctStartupCwd } : {}),
|
||||
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
|
||||
...(placement ? { placement } : {}),
|
||||
origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding)
|
||||
},
|
||||
absentOwner: ctx.args.paneSpawnOperation?.absentOwner,
|
||||
refusal: 'terminal_split_source_not_found',
|
||||
failureLog: '[pty] failed to persist runtime PTY binding after spawn:'
|
||||
}
|
||||
reportRuntimeResumeOutcome(ctx)
|
||||
if (ctx.hostSessionBinding) {
|
||||
try {
|
||||
const { store, worktreeId, tabId, leafId, expectedSourceBinding, placement } =
|
||||
ctx.hostSessionBinding
|
||||
const binding = {
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
ptyId: ctx.result.id,
|
||||
// The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile.
|
||||
mayCreate: false,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(savedStartupCwd ? { startupCwd: savedStartupCwd } : {}),
|
||||
...(correctStartupCwd ? { correctStartupCwd } : {}),
|
||||
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
|
||||
...(placement ? { placement } : {}),
|
||||
origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding)
|
||||
}
|
||||
const persisted = await persistFreshPaneBinding(
|
||||
store,
|
||||
binding,
|
||||
args.connectionId,
|
||||
args.paneSpawnOperation?.absentOwner
|
||||
)
|
||||
if (persisted === false) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
|
||||
await discardUnpersistedPtySpawn(ctx.provider, ctx.result, ctx.deps.runtime)
|
||||
if (
|
||||
err instanceof PaneOwnerContentionError ||
|
||||
(err instanceof Error && err.message === 'terminal_split_source_not_found')
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(err)), {
|
||||
agentSessionOperationOutcome: 'unknown' as const
|
||||
})
|
||||
}
|
||||
}
|
||||
if (args.worktreeId) {
|
||||
const rejectedRegistration = registerPersistedPtySpawn(
|
||||
ctx.deps.runtime,
|
||||
ctx.hostSessionBinding?.store ?? ctx.deps.store,
|
||||
ctx.pendingRegistrationClaim,
|
||||
ctx.result.id,
|
||||
args.worktreeId,
|
||||
null,
|
||||
// Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587).
|
||||
typeof args.tabId === 'string' &&
|
||||
isValidTerminalTabId(args.tabId) &&
|
||||
args.tabId.length <= 512 &&
|
||||
ctx.metadataLeafId !== null
|
||||
? {
|
||||
tabId: args.tabId,
|
||||
leafId: ctx.metadataLeafId,
|
||||
...(args.preAllocatedHandle ? { terminalHandle: args.preAllocatedHandle } : {}),
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
: undefined,
|
||||
shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd)
|
||||
)
|
||||
if (rejectedRegistration) {
|
||||
await rejectedRegistration
|
||||
}
|
||||
} else {
|
||||
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
|
||||
ctx.deps.runtime?.cancelPendingPtyRegistration?.(
|
||||
ctx.result.id,
|
||||
ctx.result.incarnationId,
|
||||
ctx.pendingRegistrationClaim
|
||||
)
|
||||
}
|
||||
// Why after registration: a spawn discarded for a failed save or rejected for exiting during
|
||||
// start must not record facts or end a stop.
|
||||
ctx.deps.runtime?.noteTerminalSpawnCommit?.(
|
||||
ctx.result,
|
||||
ctx.hostSessionBinding?.expectedSourceBinding
|
||||
)
|
||||
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
|
||||
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
|
||||
}
|
||||
|
||||
commitRuntimePtySize(ctx, ctx.result)
|
||||
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,
|
||||
target: ctx.codexSelectionTarget,
|
||||
settings: ctx.deps.getSettings?.()
|
||||
})
|
||||
// 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) {
|
||||
ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null)
|
||||
}
|
||||
if (args.telemetry && !ctx.stablePaneOwner) {
|
||||
recordPtySpawnTelemetry(args.telemetry)
|
||||
}
|
||||
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
|
||||
const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY)
|
||||
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
|
||||
const inheritRendererReadiness =
|
||||
ctx.result.isReattach === true &&
|
||||
!pendingSerializer &&
|
||||
rendererSerializerReadiness.has(ctx.result.id)
|
||||
rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness)
|
||||
if (paneKey && pendingSerializer) {
|
||||
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id)
|
||||
}
|
||||
registerPty({
|
||||
ptyId: ctx.result.id,
|
||||
worktreeId: args.worktreeId ?? null,
|
||||
sessionId: ctx.sessionId ?? null,
|
||||
paneKey,
|
||||
pid:
|
||||
typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0
|
||||
? ctx.result.pid
|
||||
: null
|
||||
})
|
||||
// Why after commit: a spawn rejected above must not leave a published identity behind.
|
||||
seedResumeLaunchIdentityFromSpawn({
|
||||
paneKey,
|
||||
launchAgent: args.launchAgent,
|
||||
resumeProviderSession: args.resumeProviderSession,
|
||||
connectionId: args.connectionId,
|
||||
worktreeId: args.worktreeId,
|
||||
launchedAt: ctx.codexHomeLaunchStartedAt,
|
||||
resumeArgvDropped: ctx.codexResumeArgvDropped,
|
||||
result: ctx.result
|
||||
})
|
||||
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
||||
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
|
||||
ctx.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: ctx.result.id,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
startedAt: ctx.codexHomeLaunchStartedAt,
|
||||
startedSequence: ctx.codexHomeLaunchStartedSequence,
|
||||
...codexReattachedHomeRouteField(
|
||||
ctx.reattachedCodexHomeRoutes,
|
||||
ctx.result.id,
|
||||
ctx.result.isReattach === true
|
||||
),
|
||||
...(ctx.result.isReattach === true
|
||||
? { reattached: true }
|
||||
: ctx.env
|
||||
? { launchEnv: ctx.env }
|
||||
: {})
|
||||
})
|
||||
return completeRuntimeSpawnResponse(ctx)
|
||||
}
|
||||
|
||||
/** Runs inside the existing durable mutation, after source/topology checks and before any bind. */
|
||||
export async function persistFreshPaneBinding(
|
||||
store: Store,
|
||||
binding: PersistPtyBindingArgs,
|
||||
connectionId?: string | null,
|
||||
absentOwner?: AbsentPaneBinding
|
||||
): Promise<boolean> {
|
||||
let targetChanged = false
|
||||
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
const persisted = await store.persistPtyBinding(() => {
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
const paneKey = makePaneKey(binding.tabId, binding.leafId)
|
||||
if (ptyBindingIsRefused(binding, session, binding.worktreeId, paneKey, [])) {
|
||||
return binding
|
||||
}
|
||||
const bound = session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId]
|
||||
const stamp = session.terminalPtyIncarnationsByPaneKey?.[paneKey]
|
||||
if (
|
||||
bound &&
|
||||
(bound !== binding.ptyId || stamp !== binding.incarnationId) &&
|
||||
!isAbsentBindingReplay(absentOwner, bound, stamp)
|
||||
) {
|
||||
targetChanged = true
|
||||
return null
|
||||
}
|
||||
return binding
|
||||
}, hostId)
|
||||
if (targetChanged) {
|
||||
throw new PaneOwnerContentionError()
|
||||
}
|
||||
return persisted
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { isValidTerminalTabId } from '../../../shared/terminal-tab-id'
|
||||
import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session-host-authority'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { Store, PtyBindingSourceExpectation } from '../../persistence'
|
||||
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import type { PtyRegistrationClaim } from '../../runtime/runtime-terminal-state-records'
|
||||
import { markNativeWindowsConptyPty } from '../../runtime/terminal-model-query-authority'
|
||||
import type { IPtyProvider, PtySpawnResult } from '../../providers/pty-provider-contract'
|
||||
import type { CodexPaneHomeRoute } from '../../codex/codex-pane-account-registry'
|
||||
import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection'
|
||||
import type { PtySpawnTiming } from '../pty-spawn-timing'
|
||||
import type { CodexHomePtySpawnedLifecycleArgs } from './host-env/types'
|
||||
import {
|
||||
codexReattachedHomeRouteField,
|
||||
shouldSkipCodexHomeEnvForWindowsShell
|
||||
} from './host-env/codex-home'
|
||||
import { commitAttachedPtySize, type PtyGrid } from './delivery/attached-pty-size'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners'
|
||||
import { admitProviderReattachLaunchIdentity } from './pane/launch-authority'
|
||||
import type { ReplacedPaneOwner } from './pane/replaced-pane-owner'
|
||||
import { admitPtySpawnOwnership, registerPersistedPtySpawn } from './pane/spawn-registration'
|
||||
import {
|
||||
resolvePaneSpawnReservation,
|
||||
type PaneSpawnReservation,
|
||||
type PaneSpawnReservationResult
|
||||
} from './pane/spawn-reservation'
|
||||
import type { StablePaneOwner } from './pane/stable-owner'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from './pane/terminal-spawn-restore'
|
||||
import type { RuntimePtySpawnArgs } from './runtime/spawn-state'
|
||||
import { reportRuntimeResumeOutcome } from './runtime/spawn-commit-response'
|
||||
import { savePtySpawnPaneBinding, type PtySpawnPaneBinding } from './spawn-commit-binding'
|
||||
import { publishCommittedPtySpawn, type PtySpawnPaneKeyAliases } from './spawn-commit-publication'
|
||||
|
||||
/** Spawn state both lanes carry; the commit reads it and settles the slots it owns. */
|
||||
export type PtySpawnCommitState = {
|
||||
deps: {
|
||||
runtime?: OrcaRuntimeService
|
||||
getSettings?: () => GlobalSettings | undefined
|
||||
options?: { onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void }
|
||||
sendPtySpawnedToRenderer: (id: string) => void
|
||||
transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void
|
||||
syncPtyBackgroundedDelivery?: (id: string, caller: string) => void
|
||||
}
|
||||
args: Pick<
|
||||
RuntimePtySpawnArgs,
|
||||
| 'cols'
|
||||
| 'rows'
|
||||
| 'connectionId'
|
||||
| 'worktreeId'
|
||||
| 'tabId'
|
||||
| 'launchAgent'
|
||||
| 'resumeProviderSession'
|
||||
| 'onResumeOutcome'
|
||||
| 'telemetry'
|
||||
>
|
||||
result: PtySpawnResult
|
||||
provider: IPtyProvider
|
||||
cwd: string | undefined
|
||||
metadataLeafId: string | null
|
||||
pendingRegistrationClaim?: PtyRegistrationClaim
|
||||
pendingRegistrationPtyId: string | null
|
||||
paneSpawnReservationKey: string | null
|
||||
paneSpawnReservation: PaneSpawnReservation | null
|
||||
effectiveSessionAppId: string | undefined
|
||||
stablePaneOwner: StablePaneOwner | null
|
||||
sessionSizeBeforeAttach: PtyGrid | undefined
|
||||
isDaemonHostSpawn: boolean
|
||||
codexResumeHomeSelected: boolean
|
||||
codexResumeUnavailable?: boolean
|
||||
selectedCodexHomePath: string | null
|
||||
codexSelectionTarget: CodexAccountSelectionTarget
|
||||
codexHomeLaunchStartedAt: Date | undefined
|
||||
codexHomeLaunchStartedSequence: number | undefined
|
||||
reattachedCodexHomeRoutes: Map<string, CodexPaneHomeRoute | null>
|
||||
launchCommand: string | undefined
|
||||
preSpawnHiddenMarkId: string | null
|
||||
/** Window-only slots; the host lane has none of them. */
|
||||
replacedPaneOwner?: ReplacedPaneOwner | null
|
||||
agentTeamsLeaderHandle?: string | null
|
||||
exitedBeforeRegistration?: boolean
|
||||
}
|
||||
|
||||
/** What a lane knows about its started process, as data. Only the last three select behavior. */
|
||||
export type PtySpawnCommitInput<Reply extends PtySpawnResult> = {
|
||||
state: PtySpawnCommitState
|
||||
nativeWindowsConptySpawn: boolean
|
||||
/** Pane key the headless model seeds under. */
|
||||
seedPaneKey: string | null
|
||||
paneBinding?: PtySpawnPaneBinding
|
||||
expectedSourceBinding?: PtyBindingSourceExpectation
|
||||
/** A live agent session the host claimed instead of starting one. */
|
||||
adoptedAgentSession?: AgentSessionClaimedSpawnResult
|
||||
registration: {
|
||||
store: Store | undefined
|
||||
worktreeId: string | null | undefined
|
||||
shellOverride: string | undefined
|
||||
rendererLaunchAuthority: { launchToken: string; launchAgent: TuiAgent } | null
|
||||
}
|
||||
preAllocatedHandle: string | null | undefined
|
||||
spawnTiming?: PtySpawnTiming
|
||||
/** Validated pane key; serializer readiness, aliases and resume identity read it. */
|
||||
paneKey: string | null
|
||||
paneKeyAliases?: PtySpawnPaneKeyAliases
|
||||
memorySessionId: string | null
|
||||
resumeArgvDropped: boolean
|
||||
/** TEMPORARY (rows 14-18): the window reports its base env, the host its assembled env. */
|
||||
codexLaunchEnv: Record<string, string> | undefined
|
||||
initiallyHidden: boolean
|
||||
/** TEMPORARY (attachment-derived delivery lane): the renderer-scoped mark is refreshed here;
|
||||
* the runtime-owned one is marked by the host runner after this commit resolves. */
|
||||
hiddenDelivery: 'renderer-scoped' | 'runtime-owned'
|
||||
/** TEMPORARY (C6): 'drain' returns an exit during start as the drained result and settles the
|
||||
* pending registration here; 'throw' rejects and leaves that to the runner. */
|
||||
earlyExit: 'drain' | 'throw'
|
||||
/** TEMPORARY (C6): what joiners of this pane's spawn receive, and what the lane replies. */
|
||||
joinPayload: { reservation: PaneSpawnReservationResult; reply: Reply }
|
||||
}
|
||||
|
||||
/** The one commit for a freshly started process, run inside the OpenCode prompt-intent wrapper. */
|
||||
export function commitPtySpawn<Reply extends PtySpawnResult>(
|
||||
input: PtySpawnCommitInput<Reply> & { earlyExit: 'throw' }
|
||||
): Promise<Reply | Awaited<ReturnType<typeof commitAdoptedAgentSession>>>
|
||||
export function commitPtySpawn<Reply extends PtySpawnResult>(
|
||||
input: PtySpawnCommitInput<Reply>
|
||||
): Promise<Reply | PtySpawnResult>
|
||||
export async function commitPtySpawn<Reply extends PtySpawnResult>(
|
||||
input: PtySpawnCommitInput<Reply>
|
||||
): Promise<Reply | PtySpawnResult> {
|
||||
const { state } = input
|
||||
const { result } = state
|
||||
const runtime = state.deps.runtime
|
||||
admitPtySpawnOwnership(runtime, result)
|
||||
if (input.nativeWindowsConptySpawn) {
|
||||
markNativeWindowsConptyPty(result.id)
|
||||
}
|
||||
// Seed before the first disk await so live output appends to the restored history.
|
||||
seedHeadlessTerminalFromSpawnResult(runtime, result, input.seedPaneKey)
|
||||
seedTerminalRestoreRecordsFromSpawnResult(runtime, result)
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(result)
|
||||
if (input.adoptedAgentSession) {
|
||||
return commitAdoptedAgentSession(
|
||||
input,
|
||||
input.adoptedAgentSession,
|
||||
providerReattachLaunchIdentity
|
||||
)
|
||||
}
|
||||
reportRuntimeResumeOutcome({
|
||||
args: state.args,
|
||||
result,
|
||||
codexResumeUnavailable: state.codexResumeUnavailable,
|
||||
codexResumeArgvDropped: input.resumeArgvDropped
|
||||
})
|
||||
if (input.paneBinding) {
|
||||
await savePtySpawnPaneBinding(state, input.paneBinding, state.args.connectionId)
|
||||
}
|
||||
const { registration } = input
|
||||
if (registration.worktreeId) {
|
||||
const tabId = state.args.tabId
|
||||
const rejectedRegistration = registerPersistedPtySpawn(
|
||||
runtime,
|
||||
registration.store,
|
||||
state.pendingRegistrationClaim,
|
||||
result.id,
|
||||
registration.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 tabId === 'string' &&
|
||||
isValidTerminalTabId(tabId) &&
|
||||
tabId.length <= 512 &&
|
||||
state.metadataLeafId !== null
|
||||
? {
|
||||
tabId,
|
||||
leafId: state.metadataLeafId,
|
||||
...(input.preAllocatedHandle ? { terminalHandle: input.preAllocatedHandle } : {}),
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(registration.rendererLaunchAuthority
|
||||
? { agentLaunchAuthority: registration.rendererLaunchAuthority }
|
||||
: {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
: undefined,
|
||||
shouldSkipCodexHomeEnvForWindowsShell(registration.shellOverride, state.cwd)
|
||||
)
|
||||
if (rejectedRegistration) {
|
||||
if (input.earlyExit === 'throw') {
|
||||
return await rejectedRegistration
|
||||
}
|
||||
await rejectedRegistration.catch((error: unknown) => {
|
||||
if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
runtime?.cancelPendingPtyRegistration?.(
|
||||
result.id,
|
||||
result.incarnationId,
|
||||
state.pendingRegistrationClaim
|
||||
)
|
||||
state.pendingRegistrationPtyId = null
|
||||
state.exitedBeforeRegistration = true
|
||||
// The renderer drains this incarnation's buffered output and exit without publishing it live.
|
||||
return resolvePaneSpawnReservation(
|
||||
state.paneSpawnReservationKey,
|
||||
state.paneSpawnReservation,
|
||||
result
|
||||
)
|
||||
}
|
||||
} else if (input.earlyExit === 'throw') {
|
||||
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
|
||||
runtime?.cancelPendingPtyRegistration?.(
|
||||
result.id,
|
||||
result.incarnationId,
|
||||
state.pendingRegistrationClaim
|
||||
)
|
||||
} else if (state.pendingRegistrationPtyId) {
|
||||
runtime?.cancelPendingPtyRegistration?.(
|
||||
state.pendingRegistrationPtyId,
|
||||
result.incarnationId,
|
||||
state.pendingRegistrationClaim
|
||||
)
|
||||
}
|
||||
if (input.earlyExit === 'drain') {
|
||||
state.pendingRegistrationPtyId = null
|
||||
}
|
||||
return publishCommittedPtySpawn(input)
|
||||
}
|
||||
|
||||
/** An adopted claim attaches to a live owner: it publishes the owner's surface, not a new pane. */
|
||||
function commitAdoptedAgentSession<Reply extends PtySpawnResult>(
|
||||
input: PtySpawnCommitInput<Reply>,
|
||||
adopted: AgentSessionClaimedSpawnResult,
|
||||
providerReattachLaunchIdentity: ReturnType<typeof admitProviderReattachLaunchIdentity>
|
||||
): Pick<PtySpawnResult, 'id' | 'incarnationId' | 'agentSessionEnsure'> | Promise<never> {
|
||||
const { state } = input
|
||||
const { result } = state
|
||||
const runtime = state.deps.runtime
|
||||
// Why: an adopted reply can omit isReattach; derive it once so the size commit and the reservation agree.
|
||||
const adoptedResult = { ...result, isReattach: true }
|
||||
const { surface } = adopted.owner
|
||||
const rejectedRegistration = registerPersistedPtySpawn(
|
||||
runtime,
|
||||
input.registration.store,
|
||||
state.pendingRegistrationClaim,
|
||||
result.id,
|
||||
surface.worktreeId,
|
||||
null,
|
||||
{
|
||||
tabId: surface.tabId,
|
||||
leafId: surface.leafId,
|
||||
terminalHandle: surface.terminalHandle,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
)
|
||||
if (rejectedRegistration) {
|
||||
return rejectedRegistration
|
||||
}
|
||||
runtime?.noteTerminalSpawnCommit?.(result, input.expectedSourceBinding)
|
||||
runtime?.registerPreAllocatedHandleForPty(result.id, surface.terminalHandle)
|
||||
state.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: state.selectedCodexHomePath,
|
||||
reattached: true,
|
||||
startedAt: state.codexHomeLaunchStartedAt,
|
||||
startedSequence: state.codexHomeLaunchStartedSequence,
|
||||
...codexReattachedHomeRouteField(state.reattachedCodexHomeRoutes, result.id, true),
|
||||
...(input.codexLaunchEnv ? { launchEnv: input.codexLaunchEnv } : {})
|
||||
})
|
||||
commitAttachedPtySize({
|
||||
result: adoptedResult,
|
||||
requested: { cols: state.args.cols, rows: state.args.rows },
|
||||
cachedBeforeAttach: state.sessionSizeBeforeAttach,
|
||||
reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime)
|
||||
})
|
||||
resolvePaneSpawnReservation(
|
||||
state.paneSpawnReservationKey,
|
||||
state.paneSpawnReservation,
|
||||
adoptedResult
|
||||
)
|
||||
return {
|
||||
id: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
agentSessionEnsure: adopted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Store } from '../../persistence'
|
||||
import type { PersistPtyBindingArgs } from '../../persistence/loading-store/pty-binding-persistence'
|
||||
import { ptyBindingIsRefused } from '../../persistence/loading-store/pty-binding-refusals'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../shared/terminal-session-state-save-failure'
|
||||
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import type { IPtyProvider, PtySpawnResult } from '../../providers/pty-provider-contract'
|
||||
import {
|
||||
isAbsentBindingReplay,
|
||||
PaneOwnerContentionError,
|
||||
type AbsentPaneBinding
|
||||
} from './pane/pane-spawn-operation'
|
||||
import { swapReplacedPaneBinding } from './pane/pane-owner-replacement'
|
||||
import type { ReplacedPaneOwner } from './pane/replaced-pane-owner'
|
||||
import { discardUnpersistedPtySpawn } from './pane/spawn-registration'
|
||||
|
||||
/** A lane's pane binding for its new process, saved before the spawn is acknowledged. */
|
||||
export type PtySpawnPaneBinding = {
|
||||
store: Store
|
||||
binding: PersistPtyBindingArgs
|
||||
absentOwner?: AbsentPaneBinding
|
||||
/** What a refused bind throws; each lane's callers match on their own message. */
|
||||
refusal: 'terminal_pane_owner_changed' | 'terminal_split_source_not_found'
|
||||
failureLog: string
|
||||
}
|
||||
|
||||
/** Saves the binding, or a swap over the replaced owner; a failed save discards the new process. */
|
||||
export async function savePtySpawnPaneBinding(
|
||||
state: {
|
||||
result: PtySpawnResult
|
||||
provider: IPtyProvider
|
||||
replacedPaneOwner?: ReplacedPaneOwner | null
|
||||
deps: { runtime?: OrcaRuntimeService }
|
||||
},
|
||||
{ store, binding, absentOwner, refusal, failureLog }: PtySpawnPaneBinding,
|
||||
connectionId: string | null | undefined
|
||||
): Promise<void> {
|
||||
const replaced = state.replacedPaneOwner
|
||||
try {
|
||||
const persisted = replaced
|
||||
? await store.persistPtyBinding(swapReplacedPaneBinding(store, binding, replaced))
|
||||
: await persistFreshPaneBinding(store, binding, connectionId, absentOwner)
|
||||
if (persisted === false) {
|
||||
throw new Error(refusal)
|
||||
}
|
||||
if (replaced) {
|
||||
// The swap holds the live binding now: a later failure must not retire it under the same id.
|
||||
state.replacedPaneOwner = null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(failureLog, err)
|
||||
await discardUnpersistedPtySpawn(state.provider, state.result, state.deps.runtime)
|
||||
if (
|
||||
err instanceof PaneOwnerContentionError ||
|
||||
(err instanceof Error && err.message === refusal)
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(err)), {
|
||||
agentSessionOperationOutcome: 'unknown' as const
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs inside the existing durable mutation, after source/topology checks and before any bind. */
|
||||
export async function persistFreshPaneBinding(
|
||||
store: Store,
|
||||
binding: PersistPtyBindingArgs,
|
||||
connectionId?: string | null,
|
||||
absentOwner?: AbsentPaneBinding
|
||||
): Promise<boolean> {
|
||||
let targetChanged = false
|
||||
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
const persisted = await store.persistPtyBinding(() => {
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
const paneKey = makePaneKey(binding.tabId, binding.leafId)
|
||||
if (ptyBindingIsRefused(binding, session, binding.worktreeId, paneKey, [])) {
|
||||
return binding
|
||||
}
|
||||
const bound = session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId]
|
||||
const stamp = session.terminalPtyIncarnationsByPaneKey?.[paneKey]
|
||||
if (
|
||||
bound &&
|
||||
(bound !== binding.ptyId || stamp !== binding.incarnationId) &&
|
||||
!isAbsentBindingReplay(absentOwner, bound, stamp)
|
||||
) {
|
||||
targetChanged = true
|
||||
return null
|
||||
}
|
||||
return binding
|
||||
}, hostId)
|
||||
if (targetChanged) {
|
||||
throw new PaneOwnerContentionError()
|
||||
}
|
||||
return persisted
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { agentHookServer } from '../../agent-hooks/server'
|
||||
import { clearMigrationUnsupportedPtysForPaneKey } from '../../agent-hooks/migration-unsupported-pty-state'
|
||||
import { registerPty } from '../../memory/pty-registry'
|
||||
import type { PtySpawnResult } from '../../providers/pty-provider-contract'
|
||||
import {
|
||||
codexReattachedHomeRouteField,
|
||||
recordCodexPaneAccountForSpawn
|
||||
} from './host-env/codex-home'
|
||||
import { closeStartupQueryAuthorityForPty } from './provider/registry'
|
||||
import { commitAttachedPtySize } from './delivery/attached-pty-size'
|
||||
import { ptySizes } from './delivery/visibility-state'
|
||||
import { rememberPaneKeyForPty } from './pane/key-state'
|
||||
import {
|
||||
pendingByPaneKey,
|
||||
pendingPtyIdBySerializerGeneration,
|
||||
rendererSerializerReadiness
|
||||
} from './pane/serializer-state'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners'
|
||||
import { seedResumeLaunchIdentityFromSpawn } from './pane/resume-launch-identity'
|
||||
import { recordPtySpawnTelemetry } from './pane/spawn-telemetry'
|
||||
import { noteRuntimePaneCreateResult, resolvePaneSpawnReservation } from './pane/spawn-reservation'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type { PtySpawnCommitInput, PtySpawnCommitState } from './spawn-commit-assembly'
|
||||
|
||||
/** Window migration inputs: legacy and reminted spawn keys alias the pane's proven key. */
|
||||
export type PtySpawnPaneKeyAliases = {
|
||||
legacySpawnPaneKey: { paneKey: string } | null
|
||||
migrationUnsupportedPaneKey: string | null
|
||||
opaqueRemintedSpawnPaneKey: string | null
|
||||
}
|
||||
|
||||
/** Everything a registered spawn publishes, in one order for both lanes; nothing here awaits. */
|
||||
export function publishCommittedPtySpawn<Reply extends PtySpawnResult>(
|
||||
input: PtySpawnCommitInput<Reply>
|
||||
): Reply {
|
||||
const { state } = input
|
||||
const { deps, args, result } = state
|
||||
const runtime = deps.runtime
|
||||
// Why here: every spawn that survives its binding save and registration publishes once here.
|
||||
runtime?.noteTerminalSpawnCommit?.(result, input.expectedSourceBinding)
|
||||
input.spawnTiming?.log(result.id, {
|
||||
daemon: state.isDaemonHostSpawn,
|
||||
reattach: result.isReattach ?? false
|
||||
})
|
||||
recordCodexPaneAccountForSpawn({
|
||||
ptyId: result.id,
|
||||
isDaemonHostSpawn: state.isDaemonHostSpawn,
|
||||
isReattach: result.isReattach === true,
|
||||
pinnedByResume: state.codexResumeHomeSelected,
|
||||
launchCodexHomePath: state.selectedCodexHomePath,
|
||||
target: state.codexSelectionTarget,
|
||||
settings: deps.getSettings?.()
|
||||
})
|
||||
if (input.hiddenDelivery === 'renderer-scoped' && input.initiallyHidden) {
|
||||
markRendererScopedHiddenAtCommit(state)
|
||||
}
|
||||
if (input.preAllocatedHandle && !state.stablePaneOwner?.handle) {
|
||||
runtime?.registerPreAllocatedHandleForPty(result.id, input.preAllocatedHandle)
|
||||
if (state.agentTeamsLeaderHandle !== undefined) {
|
||||
// The registered handle now owns the Agent Teams leader this spawn prepared.
|
||||
state.agentTeamsLeaderHandle = null
|
||||
}
|
||||
}
|
||||
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
|
||||
commitAttachedPtySize({
|
||||
result,
|
||||
requested: { cols: args.cols, rows: args.rows },
|
||||
cachedBeforeAttach: state.sessionSizeBeforeAttach,
|
||||
reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime)
|
||||
})
|
||||
if (state.effectiveSessionAppId !== undefined && state.effectiveSessionAppId !== result.id) {
|
||||
ptySizes.delete(state.effectiveSessionAppId)
|
||||
}
|
||||
beginSerializerIncarnation(result, input.paneKey)
|
||||
// Why after registration: list/read records live main-side even when the renderer owns the model.
|
||||
seedTerminalRestoreRecordsFromSpawnResult(runtime, result)
|
||||
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
|
||||
if (!state.stablePaneOwner) {
|
||||
runtime?.noteTerminalSpawnCommand?.(result.id, state.launchCommand ?? null)
|
||||
}
|
||||
// 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.
|
||||
if (args.telemetry && !state.stablePaneOwner) {
|
||||
recordPtySpawnTelemetry(args.telemetry)
|
||||
}
|
||||
// Why: record the paneKey mapping so hook titles and clearProviderPtyState can find this PTY's per-paneKey caches.
|
||||
const rememberedPaneKey = rememberPaneKeyForPty(result.id, input.paneKey)
|
||||
if (input.paneKeyAliases) {
|
||||
registerSpawnPaneKeyAliases(result, input.paneKey, input.paneKeyAliases)
|
||||
}
|
||||
registerPty({
|
||||
ptyId: result.id,
|
||||
worktreeId: input.registration.worktreeId ?? null,
|
||||
sessionId: input.memorySessionId,
|
||||
paneKey: rememberedPaneKey,
|
||||
pid:
|
||||
typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0
|
||||
? result.pid
|
||||
: null
|
||||
})
|
||||
// Why after commit: a spawn rejected above must not leave a published identity behind.
|
||||
seedResumeLaunchIdentityFromSpawn({
|
||||
paneKey: input.paneKey,
|
||||
launchAgent: args.launchAgent,
|
||||
resumeProviderSession: args.resumeProviderSession,
|
||||
connectionId: args.connectionId,
|
||||
worktreeId: args.worktreeId,
|
||||
launchedAt: state.codexHomeLaunchStartedAt,
|
||||
resumeArgvDropped: input.resumeArgvDropped,
|
||||
result
|
||||
})
|
||||
// Why: inventory consumers cannot infer background and reattached PTYs from mounted-pane state.
|
||||
deps.sendPtySpawnedToRenderer(result.id)
|
||||
deps.options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
codexHomePath: state.selectedCodexHomePath,
|
||||
startedAt: state.codexHomeLaunchStartedAt,
|
||||
startedSequence: state.codexHomeLaunchStartedSequence,
|
||||
...codexReattachedHomeRouteField(
|
||||
state.reattachedCodexHomeRoutes,
|
||||
result.id,
|
||||
result.isReattach === true
|
||||
),
|
||||
...(result.isReattach === true
|
||||
? { reattached: true }
|
||||
: input.codexLaunchEnv
|
||||
? { launchEnv: input.codexLaunchEnv }
|
||||
: {})
|
||||
})
|
||||
resolvePaneSpawnReservation(
|
||||
state.paneSpawnReservationKey,
|
||||
state.paneSpawnReservation,
|
||||
input.joinPayload.reservation
|
||||
)
|
||||
// A view waiting on this create attaches to the accepted result before its waiter resumes.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
noteRuntimePaneCreateResult(state.paneSpawnReservationKey, input.joinPayload.reservation)
|
||||
}
|
||||
return input.joinPayload.reply
|
||||
}
|
||||
|
||||
function markRendererScopedHiddenAtCommit(state: PtySpawnCommitState): void {
|
||||
const { id } = state.result
|
||||
const transition = state.deps.transitionSpawnHiddenRendererPtyDeliveryState
|
||||
// Refresh the pre-spawn hidden mark only after this incarnation survives its save.
|
||||
transition?.(id, true)
|
||||
if (state.preSpawnHiddenMarkId !== null && state.preSpawnHiddenMarkId !== id) {
|
||||
// Defense: never strand a mark on an id the provider renamed.
|
||||
transition?.(state.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.
|
||||
state.deps.syncPtyBackgroundedDelivery?.(id, 'spawn')
|
||||
closeStartupQueryAuthorityForPty(id)
|
||||
}
|
||||
|
||||
function beginSerializerIncarnation(result: PtySpawnResult, paneKey: string | null): void {
|
||||
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
|
||||
const inheritRendererReadiness =
|
||||
result.isReattach === true && !pendingSerializer && rendererSerializerReadiness.has(result.id)
|
||||
rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness)
|
||||
// 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 (pendingSerializer) {
|
||||
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id)
|
||||
}
|
||||
}
|
||||
|
||||
function registerSpawnPaneKeyAliases(
|
||||
result: PtySpawnResult,
|
||||
paneKey: string | null,
|
||||
aliases: PtySpawnPaneKeyAliases
|
||||
): void {
|
||||
if (aliases.legacySpawnPaneKey && aliases.migrationUnsupportedPaneKey) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
aliases.legacySpawnPaneKey.paneKey,
|
||||
aliases.migrationUnsupportedPaneKey,
|
||||
result.id,
|
||||
Date.now(),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
clearMigrationUnsupportedPtysForPaneKey(aliases.migrationUnsupportedPaneKey)
|
||||
} else if (aliases.opaqueRemintedSpawnPaneKey && paneKey) {
|
||||
// Reminted $$ tokens are spawn-physical; alias them to the metadata-proven tab:leaf key.
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
aliases.opaqueRemintedSpawnPaneKey,
|
||||
paneKey,
|
||||
result.id,
|
||||
Date.now(),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
} else if (paneKey && !result.isReattach) {
|
||||
clearMigrationUnsupportedPtysForPaneKey(paneKey)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export type PtySpawnCodexResumeDeps = {
|
||||
export type PtySpawnHostEnv = {
|
||||
codexResumeLaunch: CodexResumeLaunch
|
||||
launchCommand: string | undefined
|
||||
/** Before Orca's host env; TEMPORARY (row 6 removes it): the window reports this one at commit. */
|
||||
/** Before Orca's host env; TEMPORARY (rows 14-18 remove it): the window reports this one at commit. */
|
||||
baseEnv: Record<string, string> | undefined
|
||||
env: Record<string, string> | undefined
|
||||
selectedCodexHomePath: string | null
|
||||
|
||||
@@ -237,7 +237,7 @@ export function seedPtySpawnSize(input: {
|
||||
sessionId: string | undefined
|
||||
sessionAppId: string | undefined
|
||||
isFreshSessionId: boolean
|
||||
/** TEMPORARY (C5e/C6 decide who seeds): window hidden requests are unmeasured; host requests never are. */
|
||||
/** TEMPORARY (C6 decides who seeds): window hidden requests are unmeasured; host requests never are. */
|
||||
requestIsUnmeasured: boolean
|
||||
cols: number
|
||||
rows: number
|
||||
|
||||
@@ -494,6 +494,24 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) =
|
||||
)
|
||||
})
|
||||
|
||||
it('takes back its tab and spawns nothing when the launch record cannot open', async () => {
|
||||
const runtime = hostWithWindow()
|
||||
const verdicts: Promise<AgentLaunchPaneVerdict | 'unowned'>[] = []
|
||||
const publish = runtime.publishAgentLaunchTab.getMockImplementation()!
|
||||
runtime.publishAgentLaunchTab.mockImplementation((request) => {
|
||||
const published = publish(request)
|
||||
// The pane mounts as soon as the tab exists.
|
||||
verdicts.push(paneVerdict(runtime, `${request.tabId}:${request.leafId}`))
|
||||
return published
|
||||
})
|
||||
runtime.openAgentSessionRecordStore.mockRejectedValueOnce(new Error('EACCES'))
|
||||
|
||||
await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow('EACCES')
|
||||
|
||||
expect(runtime.createTerminal).not.toHaveBeenCalled()
|
||||
await expect(verdicts[0]).resolves.toEqual({ kind: 'withdrawn' })
|
||||
})
|
||||
|
||||
it('ends every shown pane nothing spawned into in a verdict, never a blank pane', async () => {
|
||||
// A tab the window already had (created: false) whose launch failed before its spawn.
|
||||
const runtime = hostWithWindow({ reply: { created: false } })
|
||||
|
||||
Reference in New Issue
Block a user