mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Reuse resource ownership and fence restart results before attachment
This commit is contained in:
@@ -52,7 +52,7 @@ export function accountRestartSnapshot(
|
||||
}
|
||||
return {
|
||||
hostEpoch: store.getPtyBindingEpoch(),
|
||||
bindingGeneration: store.trackPtyBindingGeneration(hostId, pane.leafId),
|
||||
bindingGeneration: store.trackPtyBindingGeneration({ hostId, leafId: pane.leafId }),
|
||||
tabCreatedAt: tab.createdAt,
|
||||
owner: owner?.incarnationId ? { ptyId: owner.ptyId, incarnationId: owner.incarnationId } : null
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('replaces the exact old endpoint and commits its successor', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
await expect(invoke()).resolves.toMatchObject({ id: 'pty-new' })
|
||||
expect(h.leafBinding()).toBe('pty-new')
|
||||
@@ -90,7 +90,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('refuses same-ID/new-incarnation ownership before stopping anything', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
h.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey[paneKey] = 'inc-successor'
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
await expect(invoke()).rejects.toThrow('terminal_pane_owner_changed')
|
||||
@@ -101,7 +101,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('preserves the human diagnostic and unknown stop evidence on pre-stop failure', async () => {
|
||||
const h = installRestartHarness({ shutdownFails: true })
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
const error = await invoke().catch((e: unknown) => e)
|
||||
expect(String(error)).toContain('daemon unreachable')
|
||||
@@ -116,7 +116,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('keeps an empty existing leaf after post-stop failure and retries there without a PTY', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
h.providerSpawn.mockRejectedValueOnce(new Error('launch refused'))
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
const error = await invoke().catch((e: unknown) => e)
|
||||
@@ -144,7 +144,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
it('does not dispatch or clear a successor installed during stop', async () => {
|
||||
const stopped = Promise.withResolvers<void>()
|
||||
const h = installRestartHarness({ shutdownGate: stopped.promise })
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
const pending = invoke().catch((e: unknown) => e)
|
||||
await vi.waitFor(() => expect(h.shutdown).toHaveBeenCalledTimes(1))
|
||||
@@ -157,7 +157,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
})
|
||||
it('enforces the same condition on runtime creation and empty-leaf Retry', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
const controller = h.runtime.setPtyController.mock.lastCall?.[0]
|
||||
if (!controller?.spawn) {
|
||||
@@ -194,7 +194,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('refuses a late binding commit over a successor and preserves that successor', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
h.providerSpawn.mockImplementationOnce(async () => {
|
||||
h.store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] =
|
||||
'successor'
|
||||
@@ -207,7 +207,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
})
|
||||
it('preserves the Windows capacity diagnostic and stop proof through the final IPC formatter', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
h.providerSpawn.mockRejectedValueOnce(
|
||||
terminalStartError(
|
||||
'provider stopped',
|
||||
@@ -230,7 +230,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
|
||||
it('preserves the same proof in the RPC error data after Windows formatting', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
h.providerSpawn.mockRejectedValueOnce(
|
||||
terminalStartError(
|
||||
'provider stopped',
|
||||
@@ -266,7 +266,7 @@ describe('conditional Codex account restart at the IPC boundary', () => {
|
||||
})
|
||||
it('refuses an ensure adoption from another provider-session owner without touching that owner', async () => {
|
||||
const h = installRestartHarness()
|
||||
h.store.trackPtyBindingGeneration('local', leafId)
|
||||
h.store.trackPtyBindingGeneration({ hostId: 'local', leafId })
|
||||
registerWithFakes(mainWindow, h.runtime, h.store)
|
||||
const controller = h.runtime.setPtyController.mock.lastCall?.[0]
|
||||
if (!controller?.spawn) {
|
||||
|
||||
@@ -218,8 +218,8 @@ export function installRestartHarness(
|
||||
persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)),
|
||||
retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations),
|
||||
getPtyBindingEpoch: () => 'test-host',
|
||||
trackPtyBindingGeneration: (hostId: ExecutionHostId, leafId: string) =>
|
||||
trackPtyBindingGeneration(bindingRuntime, hostId, leafId),
|
||||
trackPtyBindingGeneration: (pane: { hostId: ExecutionHostId; leafId: string }) =>
|
||||
trackPtyBindingGeneration(bindingRuntime, pane.hostId, pane.leafId),
|
||||
getPtyBindingGeneration: (hostId: ExecutionHostId, leafId: string) =>
|
||||
ptyBindingGenerationOf(bindingRuntime, hostId, leafId)
|
||||
})
|
||||
|
||||
@@ -88,8 +88,12 @@ export class WriteSchedulingOperations {
|
||||
}
|
||||
}
|
||||
|
||||
trackPtyBindingGeneration(hostId: ExecutionHostId, leafId: string): number {
|
||||
return trackPtyBindingGeneration(this[writeSchedulingOperationsContext].runtime, hostId, leafId)
|
||||
trackPtyBindingGeneration(pane: { hostId: ExecutionHostId; leafId: string }): number {
|
||||
return trackPtyBindingGeneration(
|
||||
this[writeSchedulingOperationsContext].runtime,
|
||||
pane.hostId,
|
||||
pane.leafId
|
||||
)
|
||||
}
|
||||
getPtyBindingEpoch(): string {
|
||||
return this[writeSchedulingOperationsContext].runtime.workspaceMetadataCursor.epoch
|
||||
|
||||
@@ -49,7 +49,7 @@ export function resolveAccountRestartPane(
|
||||
}),
|
||||
accountRestart: {
|
||||
hostEpoch: store.getPtyBindingEpoch(),
|
||||
bindingGeneration: store.trackPtyBindingGeneration(hostId, pane.leafId),
|
||||
bindingGeneration: store.trackPtyBindingGeneration({ hostId, leafId: pane.leafId }),
|
||||
tabCreatedAt: tab.createdAt,
|
||||
owner: bound && incarnationId ? { ptyId: bound, incarnationId } : null
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export function selectCodexRestartStatus(
|
||||
previous.ptyIdsByTabId !== state.ptyIdsByTabId ||
|
||||
previous.tabsByWorktree !== state.tabsByWorktree ||
|
||||
previous.terminalLayoutsByTabId !== state.terminalLayoutsByTabId ||
|
||||
previous.settings?.activeRuntimeEnvironmentId !== state.settings?.activeRuntimeEnvironmentId ||
|
||||
previous.repos !== state.repos ||
|
||||
previous.worktreesByRepo !== state.worktreesByRepo ||
|
||||
previous.detectedWorktreesByRepo !== state.detectedWorktreesByRepo ||
|
||||
@@ -70,9 +69,6 @@ export function selectCodexRestartStatus(
|
||||
previous.removedRuntimeEnvironmentIds !== state.removedRuntimeEnvironmentIds
|
||||
) {
|
||||
previous = {
|
||||
settings: state.settings
|
||||
? { activeRuntimeEnvironmentId: state.settings.activeRuntimeEnvironmentId }
|
||||
: null,
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo,
|
||||
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
|
||||
|
||||
+24
-3
@@ -2,7 +2,7 @@ import { getTerminalNoticeLifetime } from '../provider-notices/terminal-notice-l
|
||||
import { waitForPromiseWithSignal } from '../../../../../shared/abort-signal-reason'
|
||||
import { useAppStore } from '@/store'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/workspace/ownership/resolve-owner'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import { getResourceExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
|
||||
import { toHostSessionPaneKey } from '../../../../../shared/workspace-layout/terminal-surface-id'
|
||||
import { makePaneKey, parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
|
||||
@@ -33,6 +33,26 @@ export function isObsoleteCodexRestartResult(
|
||||
)
|
||||
}
|
||||
|
||||
/** Claim the returned endpoint before the transport admits its process lifetime. */
|
||||
export function continueCodexAccountRestart(
|
||||
token: CodexAccountRestartToken,
|
||||
result?: { id: string; incarnationId?: string | null }
|
||||
): boolean {
|
||||
const state = useAppStore.getState(),
|
||||
request = currentCodexAccountRestart(state, token)
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
if (result && isObsoleteCodexRestartResult(request, result)) {
|
||||
state.updateCodexAccountRestart(token, null)
|
||||
return false
|
||||
}
|
||||
if (result) {
|
||||
state.updateCodexAccountRestart(token, { resultPtyId: result.id })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function prepareCodexAccountRestart(
|
||||
request: CodexAccountRestart
|
||||
): Promise<AccountRestartCondition | null> {
|
||||
@@ -51,7 +71,7 @@ async function prepareCodexAccountRestartOwner(
|
||||
if (!request.leafId || !currentCodexAccountRestart(state, token)) {
|
||||
return null
|
||||
}
|
||||
if (getExecutionHostIdForWorktree(state, request.worktreeId) !== request.hostId) {
|
||||
if (getResourceExecutionHostIdForWorktree(state, request.worktreeId) !== request.hostId) {
|
||||
state.updateCodexAccountRestart(token, null)
|
||||
return null
|
||||
}
|
||||
@@ -115,7 +135,8 @@ async function prepareCodexAccountRestartOwner(
|
||||
return null
|
||||
}
|
||||
if (
|
||||
getExecutionHostIdForWorktree(useAppStore.getState(), request.worktreeId) !== request.hostId ||
|
||||
getResourceExecutionHostIdForWorktree(useAppStore.getState(), request.worktreeId) !==
|
||||
request.hostId ||
|
||||
runtimeEnvironmentIdUnlessTwin(useAppStore.getState(), request.worktreeId) !== environmentId
|
||||
) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
|
||||
+62
@@ -17,6 +17,7 @@ import { useAppStore } from '@/store'
|
||||
import { selectCodexStaleSessionCount } from '@/components/status-bar/codex-restart-status-summary'
|
||||
import { codexAccountRestartToken } from '@/store/terminals/codex-account-restart-continuation'
|
||||
import {
|
||||
continueCodexAccountRestart,
|
||||
prepareCodexAccountRestart,
|
||||
failCodexAccountRestart,
|
||||
retryCodexAccountRestarts
|
||||
@@ -650,6 +651,67 @@ describe('Codex account continuation', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['not-started', 'confirmed-retired'] as const)(
|
||||
'ignores a late %s failure after a same-ID successor takes the pane',
|
||||
async (stop) => {
|
||||
const request = accepted()
|
||||
await prepareCodexAccountRestart(request)
|
||||
const lifetime = admitTerminalNoticeLifetime('old', {
|
||||
incarnationId: 'inc-successor',
|
||||
fresh: true
|
||||
})
|
||||
try {
|
||||
failCodexAccountRestart(
|
||||
codexAccountRestartToken(request),
|
||||
receipt(request, stop, stop === 'not-started' ? 'original' : 'empty')
|
||||
)
|
||||
expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe('old')
|
||||
expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0)
|
||||
} finally {
|
||||
retireTerminalNoticeLifetime(lifetime)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts its own same-ID replacement before the transport admits the new incarnation', async () => {
|
||||
const request = accepted()
|
||||
await prepareCodexAccountRestart(request)
|
||||
expect(
|
||||
continueCodexAccountRestart(codexAccountRestartToken(request), {
|
||||
id: 'old',
|
||||
incarnationId: 'inc-own-replacement'
|
||||
})
|
||||
).toBe(true)
|
||||
const lifetime = admitTerminalNoticeLifetime('old', {
|
||||
incarnationId: 'inc-own-replacement',
|
||||
fresh: true
|
||||
})
|
||||
try {
|
||||
const scrollback = new Map([[leaf, 'old transcript']])
|
||||
const session = mountedSpawnSession(
|
||||
request,
|
||||
scrollback,
|
||||
async () => undefined,
|
||||
'inc-own-replacement'
|
||||
)
|
||||
session.transport.getPtyId = () => 'old'
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the settlement members consumed by this callback.
|
||||
settleFreshPaneSpawn(session as never, {
|
||||
spawnedPtyId: 'old',
|
||||
accountError: null,
|
||||
accountDeferred: false,
|
||||
ownerRefused: false,
|
||||
generation: 1,
|
||||
startupOverride: null,
|
||||
options: {}
|
||||
})
|
||||
expect(scrollback.has(leaf)).toBe(false)
|
||||
expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0)
|
||||
} finally {
|
||||
retireTerminalNoticeLifetime(lifetime)
|
||||
}
|
||||
})
|
||||
|
||||
it('reopens the live moved endpoint without carrying the old request into its new pane', async () => {
|
||||
const request = accepted()
|
||||
await prepareCodexAccountRestart(request)
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { ACCOUNT_RESTART_OUTCOME_UNKNOWN } from '../../../../../../shared/terminal/pty/lifecycle/account-restart-contract'
|
||||
import { readTerminalSpawnDeferral } from '../../../../../../shared/terminal/pty/admission/spawn-admission-contract'
|
||||
import {
|
||||
setPaneSpawnDeferral,
|
||||
@@ -128,7 +129,7 @@ export async function restartDetachedAccountRequest(
|
||||
const release = holdDetachedRestartSlot(accountRestartSlotKey(key, token), () => {
|
||||
useAppStore.getState().updateCodexAccountRestart(token, {
|
||||
phase: 'unresolved',
|
||||
error: 'terminal_account_restart_outcome_unknown'
|
||||
error: ACCOUNT_RESTART_OUTCOME_UNKNOWN
|
||||
})
|
||||
onSettled?.()
|
||||
})
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { ACCOUNT_RESTART_VIEW_CHANGED } from '../../../../../../shared/terminal/pty/lifecycle/account-restart-contract'
|
||||
import {
|
||||
isObsoleteCodexRestartResult,
|
||||
failCodexAccountRestart
|
||||
@@ -49,7 +50,7 @@ export function settleFreshPaneSpawn(
|
||||
if (session.disposed || generation !== session.transportStreamGeneration) {
|
||||
useAppStore.getState().updateCodexAccountRestart(accountToken, {
|
||||
phase: 'unresolved',
|
||||
error: 'terminal_account_restart_view_changed'
|
||||
error: ACCOUNT_RESTART_VIEW_CHANGED
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { releaseDeferredCwdFence } from './deferred-cwd-spawn-fence'
|
||||
export { releaseDeferredCwdFence } from './deferred-cwd-spawn-fence'
|
||||
import { settleFreshPaneSpawn } from './fresh-pane-spawn-settlement'
|
||||
import { continueCodexAccountRestart } from '../../account-restart/codex-account-restart-execution'
|
||||
import { currentCodexAccountRestart } from '@/store/terminals/codex-account-restart-continuation'
|
||||
import { applyTerminalLaunchResult } from '../startup/terminal-launch-result-policy'
|
||||
import { isPaneOwnerRefusal } from '../host-ownership/pane-owner-recovery'
|
||||
@@ -116,9 +117,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
}
|
||||
: {}),
|
||||
...(session.userRestart ? { claimReplacedPtyId: session.claimPendingReplacedPtyId } : {}),
|
||||
shouldContinue: () =>
|
||||
(!accountToken ||
|
||||
Boolean(currentCodexAccountRestart(useAppStore.getState(), accountToken))) &&
|
||||
shouldContinue: (result) =>
|
||||
(!accountToken || continueCodexAccountRestart(accountToken, result)) &&
|
||||
!session.disposed &&
|
||||
(findTerminalTabForPane(useAppStore.getState(), session.deps.worktreeId, session.deps.tabId)
|
||||
?.generation ?? 0) === session.tabGeneration,
|
||||
|
||||
@@ -87,6 +87,9 @@ export async function connectIpcPty(
|
||||
// Fence before spawning: redeployed relays recycle IDs, so earlier buffered state belongs to a previous PTY lifetime.
|
||||
const priorIncarnationFence = currentPreHandlerPtySequence()
|
||||
const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId)
|
||||
if (options.accountRestart && options.shouldContinue && !options.shouldContinue(spawnResult)) {
|
||||
return
|
||||
}
|
||||
settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, spawnResult.id)
|
||||
// Any resolved spawn or cold restore is live under this id, even if this transport was destroyed meanwhile.
|
||||
markTerminalProviderSessionBound(spawnResult.id)
|
||||
|
||||
+32
@@ -110,6 +110,38 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => {
|
||||
expect(connected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not bind or kill a late account-restart result after its pane owner changed', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const pending = Promise.withResolvers<{ id: string; incarnationId: string }>()
|
||||
vi.mocked(window.api.pty.spawn).mockReturnValueOnce(pending.promise)
|
||||
let current = true
|
||||
const onPtySpawn = vi.fn()
|
||||
const transport = createIpcPtyTransport({ onPtySpawn })
|
||||
const connecting = transport.connect({
|
||||
url: '',
|
||||
accountRestart: {
|
||||
requestId: 'request',
|
||||
attemptId: 'attempt',
|
||||
hostEpoch: 'host',
|
||||
pane: {
|
||||
worktreeId: 'wt',
|
||||
tabId: 'tab',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
tabCreatedAt: 1
|
||||
},
|
||||
expected: { kind: 'empty', bindingGeneration: 1 }
|
||||
},
|
||||
shouldContinue: () => current,
|
||||
callbacks: {}
|
||||
})
|
||||
current = false
|
||||
pending.resolve({ id: 'successor-id', incarnationId: 'obsolete-incarnation' })
|
||||
expect(await connecting).toBeUndefined()
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
expect(onPtySpawn).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Missing incarnation stays unknown for old-host/synthetic exits; remote-runtime exits use a different channel.
|
||||
it('never discards on incarnation when the host names none', async () => {
|
||||
const { result, paneExit } = await connectWithExitDuringSpawn(
|
||||
|
||||
@@ -203,8 +203,8 @@ export type PtyTransport = {
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
/** Consume only at fresh spawn dispatch; main stops the returned predecessor before resolving ownership. Reattach leaves retirement with the caller. */
|
||||
claimReplacedPtyId?: () => string | null
|
||||
/** Reject a stale pane after any pre-spawn test gate but before creating a PTY. */
|
||||
shouldContinue?: () => boolean
|
||||
/** Fence dispatch and, for account restarts, acceptance before binding a returned owner. */
|
||||
shouldContinue?: (result?: { id: string; incarnationId?: string | null }) => boolean
|
||||
callbacks: PtyCallbacks
|
||||
}) => void | Promise<void | string | PtyConnectResult>
|
||||
attach: (options: {
|
||||
|
||||
+36
-1
@@ -81,6 +81,41 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => {
|
||||
return transport.connect({ url: '', resumeSpawnIntent: 'automatic-restore', callbacks })
|
||||
}
|
||||
|
||||
it('does not bind or publish a late account-restart result whose pane owner changed', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onPtySpawn = vi.fn()
|
||||
ensureResponse = okResponse({
|
||||
terminal: {
|
||||
handle: 'replacement',
|
||||
incarnationId: 'obsolete-incarnation',
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
title: 'Codex'
|
||||
}
|
||||
})
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
launchAgent: 'codex',
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF_ID,
|
||||
onPtySpawn
|
||||
})
|
||||
const result = await transport.connect({
|
||||
url: '',
|
||||
accountRestart: {
|
||||
requestId: 'request',
|
||||
attemptId: 'attempt',
|
||||
hostEpoch: 'host',
|
||||
pane: { worktreeId: 'repo1::/remote/wt', tabId: 'tab-1', leafId: LEAF_ID, tabCreatedAt: 1 },
|
||||
expected: { kind: 'empty', bindingGeneration: 1 }
|
||||
},
|
||||
shouldContinue: (result) => result === undefined,
|
||||
callbacks: {}
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
expect(onPtySpawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps saved-origin negotiation stable through an unknown outcome (capability %s)',
|
||||
async (modern) => {
|
||||
@@ -117,7 +152,7 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => {
|
||||
} else {
|
||||
expect(requests[0].params).not.toHaveProperty('resumeOrigin')
|
||||
}
|
||||
expect(onSpawnDeferred).toHaveBeenCalledWith('paused')
|
||||
expect(onSpawnDeferred).toHaveBeenCalledWith('paused', 'terminal_spawn_deferred:paused')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+10
@@ -3892,6 +3892,16 @@ export function createRemoteRuntimePtyTransport(
|
||||
return
|
||||
}
|
||||
const createdTerminal = created.terminal
|
||||
if (
|
||||
options.accountRestart &&
|
||||
options.shouldContinue &&
|
||||
!options.shouldContinue({
|
||||
id: toRemoteRuntimePtyId(createdTerminal.handle, createEnvironmentId),
|
||||
incarnationId: createdTerminal.incarnationId
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
adoptExecutionMetadata(createdTerminal)
|
||||
if (created.disposition !== undefined && tabId && createdTerminal.tabId) {
|
||||
recordWebAgentSessionHandoff({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
captureAgentAccountRestartResumeInput,
|
||||
buildPaneAccountRestartStartup
|
||||
} from '@/lib/agent-session/accounts/agent-account-restart-startup'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import { getResourceExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import {
|
||||
currentCodexRestartPanes,
|
||||
eligibleCodexRestartPtyIds,
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
currentCodexAccountRestart
|
||||
} from './codex-account-restart-continuation'
|
||||
import type { CodexAccountRestart } from './terminal-contracts'
|
||||
import type { TerminalSlice, TerminalStoreSet } from './terminal-state'
|
||||
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
|
||||
|
||||
export function createCodexAccountRestartActions(
|
||||
set: TerminalStoreSet
|
||||
set: TerminalStoreSet,
|
||||
get: TerminalStoreGet
|
||||
): Pick<
|
||||
TerminalSlice,
|
||||
'claimCodexAccountRestart' | 'updateCodexAccountRestart' | 'retryCodexAccountRestart'
|
||||
@@ -92,12 +93,12 @@ export function createCodexAccountRestartActions(
|
||||
tab?.startupCwd ??
|
||||
s.getKnownWorktreeById(
|
||||
pane.worktreeId,
|
||||
getExecutionHostIdForWorktree(s, pane.worktreeId)
|
||||
getResourceExecutionHostIdForWorktree(s, pane.worktreeId)
|
||||
)?.path,
|
||||
connectionId: getConnectionIdFromState(s, pane.worktreeId),
|
||||
shellOverride: tab?.shellOverride,
|
||||
projectRuntime: getLocalProjectExecutionRuntimeContext(s, pane.worktreeId),
|
||||
hostId: getExecutionHostIdForWorktree(s, pane.worktreeId),
|
||||
hostId: getResourceExecutionHostIdForWorktree(s, pane.worktreeId),
|
||||
phase: startup ? 'accepted' : 'failed',
|
||||
startupInput: startup ? null : startupInput,
|
||||
startup,
|
||||
@@ -176,40 +177,34 @@ export function createCodexAccountRestartActions(
|
||||
paneAccountRestarts: next,
|
||||
...(nextNotices !== notices ? { codexRestartNoticeByPtyId: nextNotices } : {})
|
||||
}
|
||||
if (
|
||||
(patch?.phase === 'failed' ||
|
||||
patch?.phase === 'deferred' ||
|
||||
patch?.phase === 'unresolved') &&
|
||||
patch.condition?.expected.kind === 'empty' &&
|
||||
request.leafId
|
||||
) {
|
||||
const layout = s.terminalLayoutsByTabId[request.tabId]
|
||||
if (layout?.ptyIdsByLeafId?.[request.leafId] === request.oldPtyId) {
|
||||
const bindings = { ...layout.ptyIdsByLeafId }
|
||||
delete bindings[request.leafId]
|
||||
const ids = (s.ptyIdsByTabId[request.tabId] ?? []).filter(
|
||||
(id) => id !== request.oldPtyId
|
||||
)
|
||||
return {
|
||||
...common,
|
||||
terminalLayoutsByTabId: {
|
||||
...s.terminalLayoutsByTabId,
|
||||
[request.tabId]: { ...layout, ptyIdsByLeafId: bindings }
|
||||
},
|
||||
ptyIdsByTabId: { ...s.ptyIdsByTabId, [request.tabId]: ids },
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[request.worktreeId]: s.tabsByWorktree[request.worktreeId].map((tab) =>
|
||||
tab.id === request.tabId && tab.ptyId === request.oldPtyId
|
||||
? { ...tab, ptyId: ids[0] ?? null }
|
||||
: tab
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return common
|
||||
})
|
||||
if (
|
||||
!patch ||
|
||||
!['failed', 'deferred', 'unresolved'].includes(patch.phase ?? '') ||
|
||||
patch.condition?.expected.kind !== 'empty'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const state = get(),
|
||||
request = currentCodexAccountRestart(state, token)
|
||||
if (!request?.leafId) {
|
||||
return
|
||||
}
|
||||
const layout = state.terminalLayoutsByTabId[request.tabId]
|
||||
if (layout?.ptyIdsByLeafId?.[request.leafId] !== request.oldPtyId) {
|
||||
return
|
||||
}
|
||||
const bindings = { ...layout.ptyIdsByLeafId }
|
||||
delete bindings[request.leafId]
|
||||
state.setTabLayout(request.tabId, { ...layout, ptyIdsByLeafId: bindings })
|
||||
const latest = get()
|
||||
if (
|
||||
currentCodexAccountRestart(latest, token) &&
|
||||
!latest.terminalLayoutsByTabId[request.tabId]?.ptyIdsByLeafId?.[request.leafId]
|
||||
) {
|
||||
latest.clearTabPtyId(request.tabId, request.oldPtyId)
|
||||
}
|
||||
},
|
||||
retryCodexAccountRestart: (token) => {
|
||||
set((s) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime'
|
||||
import { collectLeafIds } from '../../../../shared/workspace-layout/terminal-topology/terminal-pane-layout-tree'
|
||||
import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import { getResourceExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { layoutContainsLeafId } from '../../../../shared/workspace-layout/terminal-topology/terminal-pane-tree'
|
||||
@@ -83,7 +83,7 @@ export function isCurrentCodexAccountRestart(
|
||||
request: CodexAccountRestart,
|
||||
tabs?: ReadonlyMap<string, CodexRestartTabScope>
|
||||
): boolean {
|
||||
if (getExecutionHostIdForWorktree(state, request.worktreeId) !== request.hostId) {
|
||||
if (getResourceExecutionHostIdForWorktree(state, request.worktreeId) !== request.hostId) {
|
||||
return false
|
||||
}
|
||||
if (request.environmentId && request.pairingRevision !== undefined) {
|
||||
@@ -105,7 +105,11 @@ export function isCurrentCodexAccountRestart(
|
||||
return false
|
||||
}
|
||||
if (!request.leafId) {
|
||||
return (state.ptyIdsByTabId[request.tabId] ?? []).includes(request.oldPtyId)
|
||||
const incarnation = getTerminalNoticeLifetime(request.oldPtyId)?.incarnationId
|
||||
return (
|
||||
(state.ptyIdsByTabId[request.tabId] ?? []).includes(request.oldPtyId) &&
|
||||
(!request.oldIncarnationId || !incarnation || incarnation === request.oldIncarnationId)
|
||||
)
|
||||
}
|
||||
const layout = state.terminalLayoutsByTabId[request.tabId]
|
||||
if (
|
||||
@@ -115,6 +119,16 @@ export function isCurrentCodexAccountRestart(
|
||||
return false
|
||||
}
|
||||
const current = layout.ptyIdsByLeafId?.[request.leafId]
|
||||
const incarnation = current ? getTerminalNoticeLifetime(current)?.incarnationId : undefined
|
||||
if (
|
||||
current === request.oldPtyId &&
|
||||
current !== request.resultPtyId &&
|
||||
request.oldIncarnationId &&
|
||||
incarnation &&
|
||||
incarnation !== request.oldIncarnationId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !current || current === request.oldPtyId || current === request.resultPtyId
|
||||
}
|
||||
|
||||
@@ -193,7 +207,7 @@ function reconcileRetiredCodexNotices(
|
||||
const incarnation = getTerminalNoticeLifetime(request.oldPtyId)?.incarnationId
|
||||
const originalStillCurrent =
|
||||
pane &&
|
||||
getExecutionHostIdForWorktree(next, pane.worktreeId) === request.hostId &&
|
||||
getResourceExecutionHostIdForWorktree(next, pane.worktreeId) === request.hostId &&
|
||||
(!incarnation || !request.oldIncarnationId || incarnation === request.oldIncarnationId)
|
||||
if (notices === next.codexRestartNoticeByPtyId) {
|
||||
notices = { ...notices }
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createTerminalRestartActions(
|
||||
| 'dismissCodexRestartNotices'
|
||||
| 'reopenCodexRestartPrompt'
|
||||
> {
|
||||
const { queueCodexAccountRestarts, ...codexActions } = createCodexAccountRestartActions(set)
|
||||
const { queueCodexAccountRestarts, ...codexActions } = createCodexAccountRestartActions(set, get)
|
||||
return {
|
||||
consumeSuppressedPtyExit: (ptyId) => {
|
||||
let wasSuppressed = false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const ACCOUNT_RESTART_CAPABILITY = 'terminal.account-restart.v1'
|
||||
const PREFIX = 'terminal_account_restart_v1:'
|
||||
export const ACCOUNT_RESTART_OUTCOME_UNKNOWN = 'terminal_account_restart_outcome_unknown'
|
||||
export const ACCOUNT_RESTART_VIEW_CHANGED = 'terminal_account_restart_view_changed'
|
||||
|
||||
export type AccountRestartOwner =
|
||||
| { kind: 'endpoint'; ptyId: string; incarnationId: string; bindingGeneration: number }
|
||||
|
||||
Reference in New Issue
Block a user