mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(terminal): revalidate settled pane fences and negotiate host refusal
This commit is contained in:
@@ -25,13 +25,16 @@
|
||||
"coverageNotes": "macOS hidden Electron restart exercises live and absent daemon sessions. Unit contracts cover direct SSH evidence and paired host/client refusal; other operating systems and real remote topologies remain unverified.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19358"],
|
||||
"invariant": "Main reads the persisted pane resume fence and permits attachment only. Observed exit preserves the historical tab and binding; unverifiable evidence preserves ownership and permits recovery without replacement.",
|
||||
"oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. Unsupported paired resolution never certifies exit.",
|
||||
"oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. A fence committed during attachment or claim reconciliation prevents retirement and fresh provider spawn. Unsupported paired resolution never certifies exit; owner absence cannot create until host fenced-create semantics are negotiated.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-tab-survives-restart.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --retries=0"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
|
||||
"src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
|
||||
"tests/e2e/settled-worker-tab-survives-restart.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
beginPtyRegistration: vi.fn(),
|
||||
cancelPendingPtyRegistration: vi.fn(),
|
||||
assertPtyRegistrationAllowed: vi.fn(),
|
||||
preparePtyExecutionContext: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
seedHeadlessTerminal: vi.fn(),
|
||||
@@ -176,6 +177,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
|
||||
@@ -226,6 +229,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
@@ -285,6 +290,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('execute-refusal')
|
||||
const earlyAdoption = vi.spyOn(stableAdoption, 'adoptStablePane').mockResolvedValueOnce(null)
|
||||
const providerSpawn = vi.fn(async () => {
|
||||
runtime.preparePtyExecutionContext.mockClear()
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
@@ -304,6 +310,8 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
|
||||
@@ -318,6 +326,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
async (ensureClaim) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-absent-worker')
|
||||
const providerSpawn = vi.fn(async () => {
|
||||
runtime.preparePtyExecutionContext.mockClear()
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
@@ -345,10 +354,129 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
})
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
|
||||
}
|
||||
)
|
||||
it.each([false, true])(
|
||||
'carries unverifiable through the runtime controller (pre-adopted: %s)',
|
||||
async (preAdopted) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-controller')
|
||||
const spawn = vi.fn(async () => {
|
||||
throw new Error('daemon unavailable')
|
||||
})
|
||||
installDaemonTestProvider({ spawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
const result = await controller.spawn({
|
||||
...spawnArgs,
|
||||
...(preAdopted
|
||||
? {
|
||||
adoptedStablePane: {
|
||||
result: { id: spawnArgs.sessionId, reattachUnverifiable: true },
|
||||
owner: {
|
||||
ptyId: spawnArgs.sessionId,
|
||||
tabId: spawnArgs.tabId,
|
||||
leafId: spawnArgs.leafId
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
if (preAdopted) {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it.each(['ipc', 'runtime'] as const)(
|
||||
'preserves a fence committed during %s attach',
|
||||
async (entry) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-fenced-worker')
|
||||
const record =
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
|
||||
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
|
||||
]
|
||||
record.automaticResumeBlockedBy = ''
|
||||
const spawn = vi.fn(async () => {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
const result =
|
||||
entry === 'ipc'
|
||||
? await handlers.get('pty:spawn')!(null, spawnArgs)
|
||||
: await controller.spawn(spawnArgs)
|
||||
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it('refuses a claim spawn when the fence commits during owner reconciliation', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-claim-fence')
|
||||
const record =
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
|
||||
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
|
||||
]
|
||||
record.automaticResumeBlockedBy = ''
|
||||
const spawn = vi.fn(async () => ({ id: 'replacement' }))
|
||||
const listProcesses = vi.fn(async () => {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
return []
|
||||
})
|
||||
installDaemonTestProvider({ spawn, listProcesses })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
await expect(
|
||||
controller.spawn({
|
||||
...spawnArgs,
|
||||
agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface }
|
||||
})
|
||||
).resolves.toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(listProcesses).toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -542,4 +542,29 @@ describe('registerPtyHandlers', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
it.each(['renderer', 'runtime'] as const)(
|
||||
'%s kill treats daemon session-not-found as completed shutdown',
|
||||
async (entry) => {
|
||||
const id = 'daemon-missing-session'
|
||||
const shutdown = vi.fn(async () => {
|
||||
throw new Error(`Session not found: ${id}`)
|
||||
})
|
||||
installDaemonTestProvider({ shutdown })
|
||||
setPtyOwnership(id, null)
|
||||
const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() }
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
if (entry === 'renderer') {
|
||||
await expect(handlers.get('pty:kill')!(null, { id })).resolves.toBeUndefined()
|
||||
} else {
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
kill: (id: string) => boolean
|
||||
}
|
||||
expect(controller.kill(id)).toBe(true)
|
||||
}
|
||||
await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalled())
|
||||
expect(shutdown).toHaveBeenCalledTimes(1)
|
||||
const { ptyOwnership } = await import('./pty/provider/ownership-state')
|
||||
expect(ptyOwnership.has(id)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
|
||||
import type { Store } from '../../../persistence'
|
||||
import { retirePersistedStablePaneOwner } from './stable-owner-retirement'
|
||||
@@ -25,7 +25,6 @@ export type StablePaneOwner = {
|
||||
hasPersistedBinding?: true
|
||||
persistedIncarnationId?: string
|
||||
runtimeIncarnationId?: string
|
||||
automaticResumeBlocked?: true
|
||||
}
|
||||
export type StablePaneAdoption = {
|
||||
result: PtySpawnResult
|
||||
@@ -116,9 +115,6 @@ export function resolveStablePaneOwner(
|
||||
...(runtimeIncarnationId || persisted?.incarnationId
|
||||
? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId }
|
||||
: {}),
|
||||
...(isStablePaneResumeBlocked(store, paneKey, worktreeId, connectionId)
|
||||
? { automaticResumeBlocked: true as const }
|
||||
: {}),
|
||||
...(persisted ? { hasPersistedBinding: true as const } : {}),
|
||||
...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}),
|
||||
...(runtimeIncarnationId ? { runtimeIncarnationId } : {})
|
||||
@@ -182,6 +178,13 @@ export async function attachStablePaneOwner(
|
||||
args: StablePaneSpawnContext & { owner: StablePaneOwner }
|
||||
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> {
|
||||
const { owner, provider, runtime, spawnOptions } = args
|
||||
const paneKey = makePaneKey(owner.tabId, owner.leafId)
|
||||
const blockedAtAttach = isStablePaneResumeBlocked(
|
||||
args.store,
|
||||
paneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
let result: PtySpawnResult
|
||||
try {
|
||||
result = await provider.spawn({
|
||||
@@ -201,12 +204,12 @@ export async function attachStablePaneOwner(
|
||||
onPtySpawnCommitted: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
if (owner.automaticResumeBlocked) {
|
||||
if (isStablePaneResumeBlocked(args.store, paneKey, args.worktreeId, args.connectionId)) {
|
||||
return {
|
||||
owner,
|
||||
result: {
|
||||
id: owner.ptyId,
|
||||
...(isObservedPtyExitEvidence(error)
|
||||
...(blockedAtAttach && isObservedPtyExitEvidence(error)
|
||||
? { exitedBeforeAttach: true as const }
|
||||
: { reattachUnverifiable: true as const })
|
||||
}
|
||||
@@ -271,8 +274,13 @@ export async function attachStablePaneOwner(
|
||||
export async function spawnForStablePane(
|
||||
args: StablePaneSpawnContext
|
||||
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> {
|
||||
if (args.owner) {
|
||||
const attached = await attachStablePaneOwner({ ...args, owner: args.owner })
|
||||
if (attached) {
|
||||
return attached
|
||||
}
|
||||
}
|
||||
if (
|
||||
!args.owner &&
|
||||
isStablePaneResumeBlocked(
|
||||
args.store,
|
||||
args.spawnOptions.paneKey,
|
||||
@@ -285,12 +293,6 @@ export async function spawnForStablePane(
|
||||
owner: null
|
||||
}
|
||||
}
|
||||
if (args.owner) {
|
||||
const attached = await attachStablePaneOwner({ ...args, owner: args.owner })
|
||||
if (attached) {
|
||||
return attached
|
||||
}
|
||||
}
|
||||
const result = await args.provider.spawn(args.spawnOptions)
|
||||
args.onFreshSpawn?.(result)
|
||||
return { result, owner: null }
|
||||
|
||||
@@ -94,4 +94,33 @@ describe('stable-pane fenced attach evidence', () => {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rechecks a fence committed while attachment is awaiting the host', async () => {
|
||||
const { store, ptyId } = fixture(null)
|
||||
const record = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey]
|
||||
delete (record as { automaticResumeBlockedBy?: string }).automaticResumeBlockedBy
|
||||
const resolveOwner = () =>
|
||||
resolveStablePaneOwner(undefined, store as unknown as Store, paneKey, worktreeId, null)
|
||||
const owner = resolveOwner()
|
||||
let calls = 0
|
||||
const spawn = vi.fn(async () => {
|
||||
if (++calls === 1) {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
throw new SessionNotFoundError(ptyId)
|
||||
}
|
||||
return { id: 'replacement-after-fence' }
|
||||
})
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store: store as unknown as Store,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
owner,
|
||||
worktreeId,
|
||||
connectionId: null,
|
||||
resolveOwner,
|
||||
spawnOptions: { cols: 80, rows: 24, paneKey }
|
||||
})
|
||||
expect(result.result).toEqual({ id: ptyId, reattachUnverifiable: true })
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
|
||||
|
||||
describe('review host-scoped fence', () => {
|
||||
it('reads SSH policy when the identical local pane is not fenced', () => {
|
||||
const paneKey = 'tab-worker:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const worktreeId = 'folder-worker'
|
||||
const local = { sleepingAgentSessionsByPaneKey: {} }
|
||||
const remote = {
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
}
|
||||
}
|
||||
const store = { getWorkspaceSession: vi.fn((host) => (host === 'ssh:host' ? remote : local)) }
|
||||
expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,3 +17,5 @@ export function isStablePaneResumeBlocked(
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
export class StablePaneResumeBlockedError extends Error {}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
isStablePaneResumeBlocked,
|
||||
StablePaneResumeBlockedError
|
||||
} from '../pane/stable-pane-resume-fence'
|
||||
import type { PtySpawnResult } from '../../../providers/types'
|
||||
import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
@@ -62,7 +66,12 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
if (
|
||||
args.agentSessionEnsure &&
|
||||
!ctx.preAdoptedStablePane &&
|
||||
!stablePaneOwnerCandidate?.automaticResumeBlocked
|
||||
!isStablePaneResumeBlocked(
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
) {
|
||||
// Why: daemon-backed claims can outlive this controller; import all
|
||||
// proven owners before deciding that an identity is absent.
|
||||
@@ -84,6 +93,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
surface: args.agentSessionEnsure.surface,
|
||||
spawn: async () => {
|
||||
assertClientStillConnected()
|
||||
if (
|
||||
isStablePaneResumeBlocked(
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
) {
|
||||
throw new StablePaneResumeBlockedError()
|
||||
}
|
||||
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
|
||||
ctx.rejectedRegistrationCandidate = providerResult
|
||||
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
|
||||
@@ -201,6 +220,10 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
: ctx.result.wslDistro
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof StablePaneResumeBlockedError) {
|
||||
ctx.result = { id: ctx.sessionId ?? '', reattachUnverifiable: true }
|
||||
return
|
||||
}
|
||||
if (
|
||||
(ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) &&
|
||||
ctx.effectiveSessionAppId
|
||||
|
||||
+41
@@ -1,3 +1,4 @@
|
||||
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createRemoteRuntimeTransportMocks,
|
||||
@@ -26,6 +27,9 @@ describe('paired host attach evidence', () => {
|
||||
'carries host %s without subscribing or publishing a spawn',
|
||||
async (outcome) => {
|
||||
runtimeCall.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === 'status.get') {
|
||||
return { ok: true, result: { capabilities: [TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY] } }
|
||||
}
|
||||
if (method === 'terminal.resolvePane') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -142,4 +146,41 @@ describe('paired host attach evidence', () => {
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
transport.destroy?.()
|
||||
})
|
||||
it('an old host with resolvePane must not create a replacement for a retained pane', async () => {
|
||||
runtimeCall.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === 'terminal.resolvePane') {
|
||||
return { ok: false, error: { code: 'terminal_not_found', message: 'terminal_not_found' } }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'replacement',
|
||||
ptyId: 'replacement-pty',
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:pane:1'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
const result = await transport.connect({
|
||||
url: '',
|
||||
sessionId: 'remote:env-1@@retained-handle',
|
||||
callbacks: {}
|
||||
})
|
||||
expect(result).toEqual({ id: 'remote:env-1@@retained-handle', reattachUnverifiable: true })
|
||||
try {
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.create' })
|
||||
)
|
||||
} finally {
|
||||
transport.destroy?.()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,10 @@ import type {
|
||||
RuntimeTerminalResolvePane,
|
||||
RuntimeTerminalSend
|
||||
} from '../../../../shared/runtime-types'
|
||||
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability'
|
||||
import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
@@ -2234,6 +2237,15 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
return { id: options.sessionId, reattachUnverifiable: true }
|
||||
}
|
||||
const status = await callRuntime<RuntimeStatus>('status.get')
|
||||
if (destroyed || lifecycleEpoch !== connectLifecycleEpoch) {
|
||||
return
|
||||
}
|
||||
if (!status.capabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)) {
|
||||
connecting = false
|
||||
emitRecoveryState()
|
||||
return { id: options.sessionId, reattachUnverifiable: true }
|
||||
}
|
||||
}
|
||||
const commandToSend = options.command ?? command
|
||||
const startupCommandDeliveryToSend =
|
||||
|
||||
@@ -116,6 +116,7 @@ export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
'worktree.create-idempotency.v1' as const
|
||||
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
|
||||
export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const
|
||||
export const TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY = 'terminal.fenced-create.v1' as const
|
||||
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
|
||||
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
'terminal.create-idempotency.v2' as const
|
||||
@@ -250,6 +251,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
@@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities(
|
||||
new Set([
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { RUNTIME_CAPABILITIES, TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from './protocol-version'
|
||||
import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities'
|
||||
|
||||
it('advertises fenced create outcomes in host status and every paired client transport', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
|
||||
expect(remoteRuntimeClientCapabilities()).toContain(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
|
||||
})
|
||||
Reference in New Issue
Block a user