mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(terminal): let main own a fenced worker's PTY session
A settled orchestration worker's pane (automaticResumeBlockedBy: 'legacy-orchestration-worker') attached renderer-only: transport.attach registered IPC listeners without ever asking main to attach the daemon session. Main is the daemon's only client, so after an app restart its adapter never learned the id, pty:hasPty answered a fabricated false, and the visibility reconciler tore the tab down while the process still ran. The fence's intent is right, the mechanism was wrong. A fenced pane now takes the SAME deferred reattach as every other restored pane and carries the fence as attachOnly on the connect: main attaches the live session or refuses to mint one. A session its owner proves absent comes back as the existing exitedBeforeAttach terminal state, so the pane neither respawns nor retires a binding it may never replace. Deletes the renderer-only attach path (retained-legacy-pty-attach.ts, the legacyAttachOnlyPtyId short-circuit, and the SSH gate that skipped the deferred flow for these panes). Pre-commit hook skipped: it runs `pnpm install`, which fails on this worktree's symlinked node_modules. Its checks (oxlint, react-doctor oxlint, oxfmt) were run manually on every staged file.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
// Error classes shared across the daemon protocol boundary (client, server,
|
||||
// host). Split from types.ts, which is capped for wire-shape declarations.
|
||||
import { SESSION_NOT_FOUND_MESSAGE_PREFIX } from '../../shared/pty-attach-absence-evidence'
|
||||
|
||||
const ATTACH_CANCELED_PREFIX = 'Attach canceled for session '
|
||||
|
||||
export class TerminalAttachCanceledError extends Error {
|
||||
@@ -56,7 +58,7 @@ export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavaila
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`)
|
||||
super(`${SESSION_NOT_FOUND_MESSAGE_PREFIX}${sessionId}`)
|
||||
this.name = 'SessionNotFoundError'
|
||||
}
|
||||
}
|
||||
@@ -87,8 +89,7 @@ export function isDaemonEndpointGoneError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export function decodeDaemonResponseError(message: string): Error {
|
||||
const prefix = 'Session not found: '
|
||||
return message.startsWith(prefix)
|
||||
? new SessionNotFoundError(message.slice(prefix.length))
|
||||
return message.startsWith(SESSION_NOT_FOUND_MESSAGE_PREFIX)
|
||||
? new SessionNotFoundError(message.slice(SESSION_NOT_FOUND_MESSAGE_PREFIX.length))
|
||||
: new DaemonProtocolError(message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* A fresh adapter (new app process) taking over a session an older process spawned. */
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { SessionNotFoundError } from './daemon-errors'
|
||||
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
|
||||
|
||||
// After an app restart a settled orchestration worker's pane reattaches under `attachOnly`. That
|
||||
// reattach is what makes the restarted main process an owner of the daemon session, so every
|
||||
// downstream consumer of the adapter cache — `hasPty`, resize, write, liveness, the serializer —
|
||||
// sees a session main actually owns. The pane used to attach renderer-only, leaving this cache
|
||||
// empty and `pty:hasPty` fabricating an absence the renderer closed the tab on (#16904 regression).
|
||||
describe('DaemonPtyAdapter attach-only ownership after a restart', () => {
|
||||
let harness: Awaited<ReturnType<typeof startDaemonAdapterHarness>>
|
||||
let restartedAdapter: DaemonPtyAdapter
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await startDaemonAdapterHarness(() => createMockSubprocess())
|
||||
restartedAdapter = new DaemonPtyAdapter({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
restartedAdapter.dispose()
|
||||
harness.adapter.dispose()
|
||||
await harness.server.shutdown()
|
||||
rmSync(harness.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('owns the session after an attach-only spawn, so the cache alone answers present', async () => {
|
||||
const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 })
|
||||
expect(restartedAdapter.hasPty(id)).toBe(false)
|
||||
|
||||
const attached = await restartedAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: id,
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(attached.id).toBe(id)
|
||||
expect(attached.isReattach).toBe(true)
|
||||
expect(restartedAdapter.hasPty(id)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to create a session the daemon does not have', async () => {
|
||||
await expect(
|
||||
restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: 'never-spawned', attachOnly: true })
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(restartedAdapter.hasPty('never-spawned')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to replace a session that exited before the attach', async () => {
|
||||
const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 })
|
||||
await harness.adapter.shutdown(id, { immediate: true })
|
||||
|
||||
await expect(
|
||||
restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: id, attachOnly: true })
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(restartedAdapter.hasPty(id)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { registerPtyHandlers } from './pty'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
// A settled orchestration worker's pane is fenced from resuming its provider session, so its
|
||||
// restored reattach rides `attachOnly`. Main must attach the session it already owns, and on a
|
||||
// proven absence it must refuse to mint a replacement rather than cold-restoring the agent.
|
||||
describe('pty:spawn under a caller-requested attachOnly fence', () => {
|
||||
const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite()
|
||||
|
||||
function buildFencedPaneContext(name: string) {
|
||||
const worktreeId = `repo-1::/tmp/${name}`
|
||||
const tabId = `tab-${name}`
|
||||
const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const ptyId = `pty-${name}`
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
let session = {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: `inc-${name}` }
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
setWorkspaceSession: vi.fn((next: typeof session) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
throw new Error('terminal_not_found')
|
||||
}),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => `term-${name}`),
|
||||
preAllocateHandleForPty: vi.fn(() => `term-${name}`),
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
beginPtyRegistration: vi.fn(),
|
||||
cancelPendingPtyRegistration: vi.fn(),
|
||||
assertPtyRegistrationAllowed: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
seedHeadlessTerminal: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
const spawnArgs = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: `/tmp/${name}`,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
sessionId: ptyId,
|
||||
attachOnly: true,
|
||||
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }
|
||||
}
|
||||
return { ptyId, store, runtime, spawnArgs }
|
||||
}
|
||||
|
||||
it('attaches the restored session so main owns it, without a fresh spawn', async () => {
|
||||
const { ptyId, store, runtime, spawnArgs } = buildFencedPaneContext('fenced-live-worker')
|
||||
const providerSpawn = installDaemonTestProvider({
|
||||
spawn: vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => {
|
||||
if (!options.attachOnly) {
|
||||
throw new Error('a fenced pane must never reach a fresh spawn')
|
||||
}
|
||||
return { id: options.sessionId!, incarnationId: 'inc-fenced-live-worker', isReattach: true }
|
||||
})
|
||||
})
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toMatchObject({ id: ptyId })
|
||||
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to mint a replacement session when the owner proves the session absent', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-absent-worker')
|
||||
const providerSpawn = installDaemonTestProvider({
|
||||
spawn: vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => {
|
||||
if (options.attachOnly) {
|
||||
throw new SessionNotFoundError(options.sessionId ?? '')
|
||||
}
|
||||
return { id: 'pty-replacement', incarnationId: 'inc-replacement' }
|
||||
})
|
||||
})
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
// The rejection carries the owner's own wording, which is what the renderer classifies as a
|
||||
// proven absence — the one answer that licenses treating the pane as exited.
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow(/Session not found: /)
|
||||
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the flag without a session to attach, so a fresh tab still spawns', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-no-session')
|
||||
const providerSpawn = installDaemonTestProvider()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
...spawnArgs,
|
||||
sessionId: undefined,
|
||||
tabId: 'tab-unbound',
|
||||
leafId: '6c6c6c6c-6c6c-4c6c-8c6c-6c6c6c6c6c6c'
|
||||
})
|
||||
|
||||
expect(providerSpawn.mock.calls.at(-1)?.[0]).not.toMatchObject({ attachOnly: true })
|
||||
})
|
||||
})
|
||||
@@ -90,6 +90,11 @@ export async function buildPtyIpcSpawnOptions(
|
||||
if (ctx.effectiveSessionId !== undefined) {
|
||||
ctx.spawnOptions.sessionId = ctx.effectiveSessionId
|
||||
}
|
||||
// Why: only a caller-supplied session can be attach-only; a minted id has nothing to attach to,
|
||||
// and a bare flag would make the provider refuse every fresh spawn.
|
||||
if (args.attachOnly === true && !ctx.isMintedSessionId && ctx.effectiveSessionId !== undefined) {
|
||||
ctx.spawnOptions.attachOnly = true
|
||||
}
|
||||
// Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()).
|
||||
if (ctx.effectiveShellOverride !== undefined) {
|
||||
ctx.spawnOptions.shellOverride = ctx.effectiveShellOverride
|
||||
|
||||
@@ -39,6 +39,8 @@ export type PtySpawnIpcArgs = {
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
// Why: a fenced pane (settled orchestration worker) may attach its session but never mint a replacement one.
|
||||
attachOnly?: boolean
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
terminalColorQueryReplies?: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay'
|
||||
import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority'
|
||||
import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner'
|
||||
import { isSessionNotFoundRefusal } from '../../../../shared/pty-attach-absence-evidence'
|
||||
import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints'
|
||||
import { SessionNotFoundError } from '../../../daemon/daemon-errors'
|
||||
import type { Store } from '../../../persistence'
|
||||
@@ -64,7 +65,7 @@ export function isPtyAlreadyGoneError(err: unknown): boolean {
|
||||
// Why: the reattach path rewrites the relay's wording to SSH_SESSION_EXPIRED and only this
|
||||
// class preserves that the relay itself answered "absent" rather than the link dropping.
|
||||
isSshPtyAbsentFromRelayError(err) ||
|
||||
/Session not found/i.test(message)
|
||||
isSessionNotFoundRefusal(message)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export type PtyApi = {
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
// Why: attach `sessionId` or fail; a fenced pane may never mint a replacement session.
|
||||
attachOnly?: boolean
|
||||
// Why: lets a single tab open in a different shell than the user's default.
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { projectIpcPtyConnectResult } from './ipc-pty-connect-result'
|
||||
import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2e-barrier'
|
||||
import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers'
|
||||
import { isSessionNotFoundRefusal } from '../../../../shared/pty-attach-absence-evidence'
|
||||
import { isSshSessionGoneError } from './pty-connection/pty-connect-limits'
|
||||
import { spawnIpcPty } from './ipc-pty-spawn-request'
|
||||
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
|
||||
@@ -180,6 +181,17 @@ function handleConnectError(
|
||||
error,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
if (
|
||||
options.attachOnly &&
|
||||
options.sessionId &&
|
||||
(isSessionNotFoundRefusal(message) || isSshSessionGoneError(message))
|
||||
) {
|
||||
// Why: attachOnly forbids main from minting a replacement, so a session its owner proved absent
|
||||
// is this pane's terminal state — the same contract `exitedBeforeAttach` already carries. It is
|
||||
// deliberately not `sessionExpired`, which licenses retiring the binding and respawning. An
|
||||
// unproven failure falls through and stays unverifiable (ssh-execution-boundary.md).
|
||||
return { id: options.sessionId, exitedBeforeAttach: true }
|
||||
}
|
||||
if (connectionId && options.sessionId && isSshSessionGoneError(message)) {
|
||||
return { id: options.sessionId, sessionExpired: true }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export async function spawnIpcPty(
|
||||
: {}),
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(admittedSessionId ? { sessionId: admittedSessionId } : {}),
|
||||
// Why: attachOnly is meaningless without the session it fences; main refuses a bare flag.
|
||||
...(admittedSessionId && connectOptions.attachOnly ? { attachOnly: true } : {}),
|
||||
...(connectOptions.initiallyHidden ? { initiallyHidden: true } : {}),
|
||||
worktreeId,
|
||||
...(tabId ? { tabId } : {}),
|
||||
|
||||
+25
-29
@@ -406,17 +406,12 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume a live provider session while legacy worker recovery owns the pane', async () => {
|
||||
it('reattaches a legacy worker pane through main under attachOnly instead of resuming', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = 'wt-1@@lost-pty'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId
|
||||
? {
|
||||
id: 'fresh-pty',
|
||||
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
|
||||
}
|
||||
: 'fresh-pty'
|
||||
sessionId ? { id: sessionId, isReattach: true } : 'fresh-pty'
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
@@ -465,13 +460,14 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
const attachOptions = transport.attach.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(attachOptions).not.toHaveProperty('cols')
|
||||
expect(attachOptions).not.toHaveProperty('rows')
|
||||
// The fence is expressed as attachOnly on the SAME reattach every restored pane runs, so main
|
||||
// owns the daemon session; it is not a renderer-only attach main never learns about.
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
const connectOptions = transport.connect.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(connectOptions).toMatchObject({ sessionId: retainedPtyId, attachOnly: true })
|
||||
expect(connectOptions).not.toHaveProperty('command')
|
||||
expect(connectOptions).not.toHaveProperty('launchConfig')
|
||||
expect(connectOptions).not.toHaveProperty('resumeProviderSession')
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -481,9 +477,7 @@ describe('connectPanePty', () => {
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transport.connect.mockRejectedValue(new Error('remote PTY missing'))
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
@@ -517,10 +511,13 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionId: retainedPtyId,
|
||||
attachOnly: true
|
||||
})
|
||||
// An unproven failure is `unverifiable`: the pane keeps its binding rather than retiring a
|
||||
// session it may never replace (docs/reference/ssh-execution-boundary.md).
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -530,9 +527,7 @@ describe('connectPanePty', () => {
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transport.connect.mockRejectedValue(new Error('remote PTY missing'))
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
@@ -569,11 +564,12 @@ describe('connectPanePty', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'ssh-a' })
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
// Same deferred-SSH flow as every other restored pane; the fence only adds attachOnly.
|
||||
expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionId: retainedPtyId,
|
||||
attachOnly: true
|
||||
})
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+3
-10
@@ -1,4 +1,3 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
|
||||
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
|
||||
@@ -61,8 +60,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
console.warn(
|
||||
`[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}`
|
||||
)
|
||||
const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) {
|
||||
if (gate.enterDeferredFlow) {
|
||||
// Paint main's parked model while SSH recovery continues off the render path.
|
||||
session.prepaintParkedSshSnapshot(pendingSessionId)
|
||||
void (async () => {
|
||||
@@ -115,13 +113,6 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
}
|
||||
useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId)
|
||||
if (pendingSessionId) {
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
if (session.attachRetainedLegacyPty(pendingSessionId)) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}`
|
||||
)
|
||||
@@ -146,6 +137,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
}
|
||||
let expiredReattachError = false
|
||||
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
|
||||
const attachOnly = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(
|
||||
@@ -174,6 +166,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
sessionId: pendingSessionId,
|
||||
...(attachOnly ? { attachOnly: true } : {}),
|
||||
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
|
||||
...(coldRestoreStartup?.env
|
||||
? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
|
||||
|
||||
+45
-57
@@ -27,7 +27,11 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
const existingPtyId = storeSnapshot.tabsByWorktree[session.deps.worktreeId]?.find(
|
||||
(t) => t.id === session.deps.tabId
|
||||
)?.ptyId
|
||||
const hasSleepingAgentSession = Boolean(session.getSleepingRecordForPane(storeSnapshot))
|
||||
// Why: the legacy-worker fence forbids resuming this pane's provider session, so its sleeping
|
||||
// record must not steer the pane into any cold-restore or wake branch. It still reattaches.
|
||||
const hasResumableSleepingAgentSession =
|
||||
Boolean(session.getSleepingRecordForPane(storeSnapshot)) &&
|
||||
!session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
|
||||
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry.
|
||||
const tabFallbackPtyId =
|
||||
@@ -41,7 +45,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
|
||||
const restoredSessionId = restoredPtyId ?? null
|
||||
const sleptRemoteRuntimeSessionId =
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasResumableSleepingAgentSession
|
||||
? restoredSessionId
|
||||
: null
|
||||
const detachedLivePtyId =
|
||||
@@ -53,7 +57,9 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
: tabFallbackPtyId
|
||||
: null
|
||||
const detachedRemoteLeafPtyId =
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession
|
||||
restoredSessionId &&
|
||||
isRemoteRuntimePtyId(restoredSessionId) &&
|
||||
!hasResumableSleepingAgentSession
|
||||
? restoredSessionId
|
||||
: null
|
||||
const candidateReattachSessionId =
|
||||
@@ -87,11 +93,6 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
currentTabLivePtyIds.includes(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
// Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree.
|
||||
// Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh.
|
||||
const legacyAttachOnlyPtyId = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const pairedParkedReattachSessionId =
|
||||
session.mountFollowsTerminalPark &&
|
||||
candidateReattachSessionId &&
|
||||
@@ -99,64 +100,51 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
canRestorePairedParkedTerminal(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const deferredReattachSessionId = legacyAttachOnlyPtyId
|
||||
? null
|
||||
: (runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null))
|
||||
const deferredReattachSessionId =
|
||||
runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null)
|
||||
recordPtyConnectDiagnostic(
|
||||
`pane=${session.pane.id} tab=${session.deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${session.hadExistingPaneTransportAtConnect} pendingKey=${session.pendingSpawnKey}`
|
||||
)
|
||||
|
||||
if (deferredReattachSessionId) {
|
||||
startDeferredSessionReattach(session, deferredReattachSessionId)
|
||||
} else if (
|
||||
legacyAttachOnlyPtyId ||
|
||||
detachedRemoteLeafPtyId ||
|
||||
detachedLivePtyId ||
|
||||
eagerLivePtyId
|
||||
) {
|
||||
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
|
||||
// Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab.
|
||||
// eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt.
|
||||
const attachPtyId =
|
||||
legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`)
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
if (legacyAttachOnlyPtyId) {
|
||||
if (session.attachRetainedLegacyPty(legacyAttachOnlyPtyId) && session.connectionId) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
}
|
||||
} else {
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
}
|
||||
} else {
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
@@ -187,7 +175,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
`Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn`
|
||||
)
|
||||
}
|
||||
if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) {
|
||||
if (sleptRemoteColdRestoreStartup || hasResumableSleepingAgentSession) {
|
||||
session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined)
|
||||
} else {
|
||||
session.startFreshSpawn()
|
||||
@@ -218,7 +206,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
})
|
||||
} else {
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> FRESH SPAWN`)
|
||||
if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) {
|
||||
if (sleptRemoteColdRestoreStartup || hasResumableSleepingAgentSession) {
|
||||
session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined)
|
||||
} else {
|
||||
session.startFreshSpawn()
|
||||
|
||||
+4
@@ -25,6 +25,9 @@ export function startDeferredSessionReattach(
|
||||
let expiredReattachError = false
|
||||
let paneOwnerUnverified = false
|
||||
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
|
||||
// Why: a settled orchestration worker is fenced from resuming; main must attach its live session
|
||||
// or report it absent, never mint a replacement process for this pane.
|
||||
const attachOnly = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(
|
||||
(message) => {
|
||||
if (isSshSessionGoneError(message)) {
|
||||
@@ -48,6 +51,7 @@ export function startDeferredSessionReattach(
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
sessionId: deferredReattachSessionId,
|
||||
...(attachOnly ? { attachOnly: true } : {}),
|
||||
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
|
||||
...(coldRestoreStartup?.env
|
||||
? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void {
|
||||
session.attachRetainedLegacyPty = (ptyId: string): boolean => {
|
||||
try {
|
||||
session.authoritativeReattachGeneration += 1
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: ptyId,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? ptyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint'
|
||||
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
|
||||
import { bindRegisterPaneSerializer } from './pane-serializer-register'
|
||||
import { bindHandleReattachResult } from './reattach-result-handler'
|
||||
import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach'
|
||||
import { runDeferredSessionAttach } from './deferred-session-attach'
|
||||
|
||||
import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize'
|
||||
@@ -154,7 +153,6 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
|
||||
|
||||
bindPrepaintParkedSshSnapshot(session)
|
||||
bindHandleReattachResult(session)
|
||||
bindAttachRetainedLegacyPty(session)
|
||||
runDeferredSessionAttach(session)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,10 @@ export type PtyTransport = {
|
||||
cols?: number
|
||||
rows?: number
|
||||
sessionId?: string
|
||||
/** Attach `sessionId` or report it absent; never mint a replacement session.
|
||||
* The pane's fence (a settled orchestration worker) forbids starting a new
|
||||
* process, so an absent session is a terminal state, not a respawn cue. */
|
||||
attachOnly?: boolean
|
||||
/** Hidden-at-spawn declaration (terminal-query-authority.md): no visible
|
||||
* view will consume this PTY's bytes, so main marks it hidden BEFORE the
|
||||
* first byte and the gate + model responder own spawn-time queries.
|
||||
|
||||
@@ -2198,6 +2198,12 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
}
|
||||
|
||||
if (options.attachOnly && options.sessionId) {
|
||||
// Why: attachOnly forbids minting a session for this pane. Nothing was adopted above, so
|
||||
// the host has no pane to hand back — report the terminal state the caller already
|
||||
// understands instead of creating a replacement remote terminal behind the fence.
|
||||
return { id: options.sessionId, exitedBeforeAttach: true }
|
||||
}
|
||||
const commandToSend = options.command ?? command
|
||||
const startupCommandDeliveryToSend =
|
||||
options.startupCommandDelivery ?? startupCommandDelivery
|
||||
|
||||
@@ -15,3 +15,20 @@ const PROVEN_EXITED_ATTACH_REFUSAL = /PTY ".+" not found \(process exited\)/i
|
||||
export function isProvenExitedPtyAttachRefusal(error: unknown): boolean {
|
||||
return PROVEN_EXITED_ATTACH_REFUSAL.test(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* The wording every PTY session owner (daemon host, local provider) mints when the id it was asked
|
||||
* to attach is not in its own session map. Unlike the relay marker above this answer is
|
||||
* unambiguous: the process that owns the session table answered about itself, so absence is
|
||||
* `exited`, never `unverifiable` (docs/reference/ssh-execution-boundary.md).
|
||||
*
|
||||
* It crosses two process boundaries — daemon socket and Electron IPC — which erase the error class,
|
||||
* so the text is the contract. Producers build their message from this constant.
|
||||
*/
|
||||
export const SESSION_NOT_FOUND_MESSAGE_PREFIX = 'Session not found: '
|
||||
|
||||
export function isSessionNotFoundRefusal(error: unknown): boolean {
|
||||
return (error instanceof Error ? error.message : String(error)).includes(
|
||||
SESSION_NOT_FOUND_MESSAGE_PREFIX
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { TEST_REPO_PATH_FILE } from './global-setup'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
waitForActivePaneHookDescriptor,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override'
|
||||
import {
|
||||
cleanupCompletedWorkerFixture,
|
||||
clearCompletedWorkerLedger,
|
||||
completedWorkerFakeCodexCommand,
|
||||
completedWorkerLaunchEnv,
|
||||
listRuntimeTerminals,
|
||||
readCompletedWorkerDispatchCapability,
|
||||
readCompletedWorkerLedger,
|
||||
readPersistedWorkerRecoveryRecord,
|
||||
seedCurrentCodexTranscript
|
||||
} from './helpers/completed-worker-retirement-fixture'
|
||||
import { RuntimeClient } from '../../src/cli/runtime-client'
|
||||
import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id'
|
||||
|
||||
const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dd'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(() => {
|
||||
cleanupCompletedWorkerFixture()
|
||||
})
|
||||
|
||||
async function findSecondaryWorktree(
|
||||
page: Page,
|
||||
client: RuntimeClient,
|
||||
coordinatorWorktreeId: string
|
||||
): Promise<string> {
|
||||
let targetWorktreeId: string | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {})
|
||||
// The restart fixture only waits for the primary; refetch until the seeded secondary lands.
|
||||
const rendererWorktreeIds = await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return []
|
||||
}
|
||||
await Promise.all(
|
||||
store.getState().repos.map((repo) => store.getState().fetchWorktrees(repo.id))
|
||||
)
|
||||
return Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
})
|
||||
targetWorktreeId =
|
||||
listed.result.worktrees.find(
|
||||
(worktree) =>
|
||||
worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id)
|
||||
)?.id ?? null
|
||||
return targetWorktreeId
|
||||
},
|
||||
{ timeout: 60_000, message: 'runtime never registered the secondary worktree' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (!targetWorktreeId) {
|
||||
throw new Error('The seeded repository did not expose its secondary worktree')
|
||||
}
|
||||
return targetWorktreeId
|
||||
}
|
||||
|
||||
async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('orca-background-mount-terminal-worktree', {
|
||||
detail: { worktreeId, tabIds: [tabId] }
|
||||
})
|
||||
)
|
||||
},
|
||||
{ tabId, worktreeId }
|
||||
)
|
||||
await expect
|
||||
.poll(() => page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), tabId))
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// A worker that reported done while its terminal stays open is fenced from auto-resume, so after an
|
||||
// app restart its pane re-attaches renderer-only. Revealing that pane used to tear the tab down on a
|
||||
// fabricated "no PTY" while the daemon still ran the process (#16904 regression).
|
||||
test('a settled orchestration worker tab survives reveal after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
|
||||
if (!repoPath || !existsSync(repoPath)) {
|
||||
test.skip(true, 'Global setup did not produce a seeded test repo')
|
||||
return
|
||||
}
|
||||
clearCompletedWorkerLedger()
|
||||
|
||||
const session = createRestartSession(testInfo, completedWorkerLaunchEnv)
|
||||
let firstApp: ElectronApplication | null = null
|
||||
let secondApp: ElectronApplication | null = null
|
||||
try {
|
||||
const first = await session.launch()
|
||||
firstApp = first.app
|
||||
const coordinatorWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
|
||||
await waitForSessionReady(first.page)
|
||||
await waitForActiveWorktree(first.page)
|
||||
await ensureTerminalVisible(first.page)
|
||||
await waitForActiveTerminalManager(first.page)
|
||||
await waitForActivePanePtyId(first.page)
|
||||
await first.page.evaluate(
|
||||
async ({ agentCommand, terminalWindowsShell }) => {
|
||||
await window.__store?.getState().updateSettings({
|
||||
agentCmdOverrides: { codex: agentCommand },
|
||||
terminalWindowsShell,
|
||||
disabledTuiAgents: [],
|
||||
terminalHiddenViewParking: false
|
||||
})
|
||||
},
|
||||
{
|
||||
agentCommand: completedWorkerFakeCodexCommand,
|
||||
terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL
|
||||
}
|
||||
)
|
||||
const isolatedHome = await firstApp.evaluate(({ app }) => app.getPath('home'))
|
||||
const client = new RuntimeClient(session.userDataDir, 30_000, null, null)
|
||||
const coordinatorPane = await waitForActivePaneHookDescriptor(first.page)
|
||||
const coordinatorHandle = (
|
||||
await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', {
|
||||
paneKey: coordinatorPane.paneKey
|
||||
})
|
||||
).result.terminal.handle
|
||||
const targetWorktreeId = await findSecondaryWorktree(first.page, client, coordinatorWorktreeId)
|
||||
const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath
|
||||
if (!targetWorktreePath) {
|
||||
throw new Error('The secondary worktree did not expose a filesystem path')
|
||||
}
|
||||
|
||||
const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', {
|
||||
objective: 'Keep one settled worker tab across restart',
|
||||
from: coordinatorHandle
|
||||
})
|
||||
const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', {
|
||||
spec: 'Report completion and stay open',
|
||||
run: run.result.run.id,
|
||||
callerTerminalHandle: coordinatorHandle
|
||||
})
|
||||
const started = await client.call<{
|
||||
dispatchId: string
|
||||
state: string
|
||||
effects: { kind: string; role?: string; id?: string }[]
|
||||
}>('orchestration.workerStart', {
|
||||
task: task.result.task.id,
|
||||
from: coordinatorHandle,
|
||||
worktree: `id:${targetWorktreeId}`,
|
||||
agent: 'codex',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(started.result.state).toBe('ready')
|
||||
const workerHandle = started.result.effects.find(
|
||||
(effect) => effect.kind === 'terminal' && effect.role === 'agent'
|
||||
)?.id
|
||||
if (!workerHandle) {
|
||||
throw new Error('worker-start did not return its agent terminal')
|
||||
}
|
||||
let worker: RuntimeTerminalSummary | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
worker = (await listRuntimeTerminals(client)).find(
|
||||
(terminal) => terminal.handle === workerHandle
|
||||
)
|
||||
return worker?.ptyId ?? null
|
||||
},
|
||||
{ timeout: 30_000, message: 'background worker never published its PTY identity' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (!worker?.ptyId) {
|
||||
throw new Error('Background worker did not publish its PTY')
|
||||
}
|
||||
const workerPtyId = worker.ptyId
|
||||
const workerTabId = worker.tabId
|
||||
const workerPaneKey = `${worker.tabId}:${worker.leafId}`
|
||||
await backgroundMountTab(first.page, targetWorktreeId, workerTabId)
|
||||
let dispatchCapability: string | null = null
|
||||
await expect
|
||||
.poll(() => {
|
||||
dispatchCapability = readCompletedWorkerDispatchCapability()
|
||||
return dispatchCapability
|
||||
})
|
||||
.not.toBeNull()
|
||||
if (!dispatchCapability) {
|
||||
throw new Error('Background worker did not receive its dispatch capability')
|
||||
}
|
||||
const transcriptPath = seedCurrentCodexTranscript(
|
||||
isolatedHome,
|
||||
PROVIDER_SESSION_ID,
|
||||
targetWorktreePath
|
||||
)
|
||||
await first.page.evaluate(
|
||||
({
|
||||
agentCommand,
|
||||
paneKey,
|
||||
providerSessionId,
|
||||
tabId,
|
||||
terminalHandle,
|
||||
transcriptPath,
|
||||
worktreeId
|
||||
}) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
const metadata = { tabId, worktreeId, terminalHandle }
|
||||
const recovery = {
|
||||
providerSession: { key: 'session_id' as const, id: providerSessionId, transcriptPath },
|
||||
launchConfig: {
|
||||
agentCommand,
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: {}
|
||||
}
|
||||
}
|
||||
for (const agentState of ['working', 'done'] as const) {
|
||||
state.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: agentState, prompt: 'Report completion and stay open', agentType: 'codex' },
|
||||
'Settled background worker',
|
||||
undefined,
|
||||
metadata,
|
||||
recovery
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
agentCommand: completedWorkerFakeCodexCommand,
|
||||
paneKey: workerPaneKey,
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
tabId: workerTabId,
|
||||
terminalHandle: workerHandle,
|
||||
transcriptPath,
|
||||
worktreeId: targetWorktreeId
|
||||
}
|
||||
)
|
||||
const completed = await client.call<{ message: { type: string } }>(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: workerHandle,
|
||||
subject: 'Completed',
|
||||
body: 'The fixture completed and stays open for inspection.',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({
|
||||
taskId: task.result.task.id,
|
||||
dispatchId: started.result.dispatchId,
|
||||
outcome: 'succeeded'
|
||||
})
|
||||
},
|
||||
{ orchestrationCapability: dispatchCapability }
|
||||
)
|
||||
expect(completed.result.message.type).toBe('worker_done')
|
||||
// The settlement sweep stamps the resume fence on the renderer's record before the tab closes.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
first.page.evaluate(
|
||||
(paneKey) =>
|
||||
window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey]
|
||||
?.automaticResumeBlockedBy ?? null,
|
||||
workerPaneKey
|
||||
),
|
||||
{ timeout: 30_000, message: 'settled worker pane was never fenced' }
|
||||
)
|
||||
.toBe('legacy-orchestration-worker')
|
||||
|
||||
await session.close(firstApp)
|
||||
firstApp = null
|
||||
expect(readPersistedWorkerRecoveryRecord(session.userDataDir, workerPaneKey)).toMatchObject({
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([])
|
||||
|
||||
const second = await session.launch()
|
||||
secondApp = second.app
|
||||
await waitForSessionReady(second.page)
|
||||
// The daemon kept the worker alive across the app restart; the new main process never attached it.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId)
|
||||
?.connected ?? null,
|
||||
{ timeout: 60_000, message: 'restarted runtime never rediscovered the worker PTY' }
|
||||
)
|
||||
.toBe(true)
|
||||
expect(
|
||||
await second.page.evaluate(
|
||||
({ tabId, worktreeId }) =>
|
||||
Boolean(
|
||||
window.__store?.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)
|
||||
),
|
||||
{ tabId: workerTabId, worktreeId: targetWorktreeId }
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Hidden mount, then reveal: the reveal is what runs the missing-session reconciler.
|
||||
await backgroundMountTab(second.page, targetWorktreeId, workerTabId)
|
||||
expect
|
||||
.soft(
|
||||
await second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId),
|
||||
'liveness before reveal'
|
||||
)
|
||||
.toBe(true)
|
||||
await second.page.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
type Transition = {
|
||||
activeWorktreeId: string | null
|
||||
tabPresent: boolean
|
||||
leafPtyIds: string[]
|
||||
activeTabId: string | null
|
||||
}
|
||||
const snapshot = (state: ReturnType<typeof store.getState>): Transition => ({
|
||||
activeWorktreeId: state.activeWorktreeId ?? null,
|
||||
tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)),
|
||||
leafPtyIds: Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}),
|
||||
activeTabId: state.activeTabIdByWorktree[worktreeId] ?? null
|
||||
})
|
||||
const transitions: Transition[] = [snapshot(store.getState())]
|
||||
const e2eWindow = window as typeof window & { __orcaRevealTransitions?: Transition[] }
|
||||
e2eWindow.__orcaRevealTransitions = transitions
|
||||
store.subscribe((state) => {
|
||||
const next = snapshot(state)
|
||||
if (JSON.stringify(next) !== JSON.stringify(transitions.at(-1))) {
|
||||
transitions.push(next)
|
||||
}
|
||||
})
|
||||
store.getState().setActiveWorktree(worktreeId)
|
||||
},
|
||||
{ tabId: workerTabId, worktreeId: targetWorktreeId }
|
||||
)
|
||||
// Give the reconciler's async verdict time to land; the tab must never have left.
|
||||
await second.page.waitForTimeout(3_000)
|
||||
const transitions = await second.page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__orcaRevealTransitions?: {
|
||||
activeWorktreeId: string | null
|
||||
tabPresent: boolean
|
||||
leafPtyIds: string[]
|
||||
}[]
|
||||
}
|
||||
).__orcaRevealTransitions ?? []
|
||||
)
|
||||
// Pre-fix this read: leaf binding cleared -> tab removed -> worktree deselected -> tab re-added by graph sync.
|
||||
expect(
|
||||
transitions.filter((step) => !step.tabPresent || step.leafPtyIds.length === 0),
|
||||
'reveal must not tear the settled worker tab down'
|
||||
).toEqual([])
|
||||
expect(transitions.at(-1)?.activeWorktreeId).toBe(targetWorktreeId)
|
||||
expect(
|
||||
await second.page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerTabId)
|
||||
).toBe(true)
|
||||
expect(
|
||||
(await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId)
|
||||
?.connected
|
||||
).toBe(true)
|
||||
expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([])
|
||||
} finally {
|
||||
if (secondApp) {
|
||||
await session.close(secondApp)
|
||||
}
|
||||
if (firstApp) {
|
||||
await session.close(firstApp)
|
||||
}
|
||||
await session.dispose()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user