mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
fix: preserve live agent PTYs through graph hydration (#11789)
This commit is contained in:
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
|
||||
describe('foreground-confirmation daemon protocol', () => {
|
||||
it('rejects daemons from before the fresh-confirmation RPC', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(30)
|
||||
expect(PROTOCOL_VERSION).toBe(31)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
|
||||
@@ -13,5 +13,6 @@ describe('foreground-confirmation daemon protocol', () => {
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION,
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
|
||||
describe('daemon protocol version', () => {
|
||||
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(30)
|
||||
expect(PROTOCOL_VERSION).toBe(31)
|
||||
expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31)
|
||||
expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30)
|
||||
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
|
||||
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
|
||||
@@ -21,7 +23,7 @@ describe('daemon protocol version', () => {
|
||||
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
|
||||
Array.from({ length: 29 }, (_, index) => index + 1)
|
||||
Array.from({ length: 30 }, (_, index) => index + 1)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Why: daemons survive app updates, so wire behavior must be version-gated.
|
||||
// v30 transfers large cold-restore seeds across bounded NDJSON messages.
|
||||
export const PROTOCOL_VERSION = 30
|
||||
// v31 exposes attach-only PTY adoption so a mount cannot create over a live stable pane.
|
||||
export const PROTOCOL_VERSION = 31
|
||||
export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
|
||||
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
|
||||
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
|
||||
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
|
||||
@@ -22,7 +23,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
|
||||
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28, 29
|
||||
28, 29, 30
|
||||
] as const
|
||||
|
||||
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
|
||||
|
||||
@@ -1411,6 +1411,69 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(result.snapshot).toBeUndefined()
|
||||
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
|
||||
})
|
||||
|
||||
it('forwards attach-only and never creates an absent stable session', async () => {
|
||||
const subprocessBeforeAttach = lastSubprocess
|
||||
await expect(
|
||||
adapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'missing-stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
).rejects.toThrow('Session not found: missing-stable-pane-session')
|
||||
expect(lastSubprocess).toBe(subprocessBeforeAttach)
|
||||
})
|
||||
|
||||
it('does not inspect cold history for attach-only ownership checks', async () => {
|
||||
const historyDir = join(dir, 'attach-only-history')
|
||||
const historyAdapter = new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
historyPath: historyDir
|
||||
})
|
||||
const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader
|
||||
const probe = vi.spyOn(reader, 'probeRestorableHistory')
|
||||
const getAppliedSize = vi.spyOn(historyAdapter, 'getAppliedSize')
|
||||
|
||||
try {
|
||||
await expect(
|
||||
historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'missing-attach-only-history-session',
|
||||
attachOnly: true
|
||||
})
|
||||
).rejects.toThrow('Session not found: missing-attach-only-history-session')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(getAppliedSize).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
historyAdapter.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed before dispatching attach-only to a v30 daemon', async () => {
|
||||
const ensureConnected = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const request = vi.spyOn(DaemonClient.prototype, 'request')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
|
||||
try {
|
||||
await expect(
|
||||
legacy.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'legacy-stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
).rejects.toThrow('terminal_pane_owner_unknown')
|
||||
expect(request).not.toHaveBeenCalledWith('createOrAttach', expect.anything())
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
request.mockRestore()
|
||||
ensureConnected.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('attach', () => {
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
type SessionInfo,
|
||||
type TakePendingOutputResult
|
||||
} from './types'
|
||||
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import {
|
||||
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
||||
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
|
||||
} from './daemon-protocol-version'
|
||||
import {
|
||||
isAgentSessionClaimedSpawnResult,
|
||||
isAgentSessionOwnerBinding,
|
||||
@@ -331,6 +334,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
if (opts.attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) {
|
||||
throw new Error('terminal_pane_owner_unknown')
|
||||
}
|
||||
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
|
||||
const operation = {
|
||||
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
|
||||
@@ -449,7 +455,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
|
||||
let restoreInfo: ColdRestoreInfo | null = null
|
||||
let restoreSkippedForLiveSession = false
|
||||
const historyProbe = this.historyReader?.probeRestorableHistory(sessionId)
|
||||
const historyProbe = opts.attachOnly
|
||||
? undefined
|
||||
: this.historyReader?.probeRestorableHistory(sessionId)
|
||||
if (historyProbe && historyProbe.status !== 'none') {
|
||||
if ((await this.getAppliedSize(sessionId)) !== null) {
|
||||
restoreSkippedForLiveSession = true
|
||||
@@ -496,6 +504,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
command: opts.command,
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
launchAgent: opts.launchAgent,
|
||||
...(opts.attachOnly ? { attachOnly: true } : {}),
|
||||
// Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider.
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { SubprocessHandle } from './session'
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((callback) => {
|
||||
onExit = callback
|
||||
}),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('DaemonServer attach-only preparation', () => {
|
||||
const servers: DaemonServer[] = []
|
||||
const clients: DaemonClient[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const client of clients.splice(0)) {
|
||||
client.disconnect()
|
||||
}
|
||||
await Promise.all(servers.splice(0).map((server) => server.shutdown()))
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips fresh-spawn preparation when attaching only', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'daemon-attach-only-test-'))
|
||||
directories.push(directory)
|
||||
const socketPath = getDaemonSocketPath(directory)
|
||||
const tokenPath = join(directory, 'test.token')
|
||||
const preparePtySpawn = vi.fn(async () => {})
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
preparePtySpawn,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
servers.push(server)
|
||||
await server.start()
|
||||
const client = new DaemonClient({ socketPath, tokenPath })
|
||||
clients.push(client)
|
||||
await client.ensureConnected()
|
||||
await client.request('createOrAttach', {
|
||||
sessionId: 'stable-pane-session',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.request('createOrAttach', {
|
||||
sessionId: 'stable-pane-session',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
attachOnly: true
|
||||
})
|
||||
).resolves.toMatchObject({ isNew: false })
|
||||
expect(preparePtySpawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -733,7 +733,9 @@ export class DaemonServer {
|
||||
) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
|
||||
if (!p.attachOnly) {
|
||||
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
|
||||
}
|
||||
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
|
||||
throw new Error('Multiple terminal history seed sources')
|
||||
}
|
||||
@@ -752,6 +754,7 @@ export class DaemonServer {
|
||||
envToDelete: p.envToDelete,
|
||||
command: p.command,
|
||||
startupCommandDelivery: p.startupCommandDelivery,
|
||||
...(p.attachOnly === true ? { attachOnly: true } : {}),
|
||||
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
|
||||
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
|
||||
shellOverride: p.shellOverride,
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos
|
||||
|
||||
export type InternalCreateOrAttachOptions = CreateOrAttachOptions & {
|
||||
agentSessionGeneration?: string
|
||||
attachOnly?: boolean
|
||||
}
|
||||
|
||||
export async function createOrAttachClaimedAgentSession(args: {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
|
||||
|
||||
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
|
||||
|
||||
describe('TerminalHost attach-only sessions', () => {
|
||||
let host: TerminalHost
|
||||
let spawnSubprocess: Mock<SpawnSubprocess>
|
||||
|
||||
beforeEach(() => {
|
||||
spawnSubprocess = vi.fn<SpawnSubprocess>(() => {
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
return {
|
||||
pid: 99999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((callback) => {
|
||||
onExit = callback
|
||||
}),
|
||||
dispose: vi.fn()
|
||||
} as SubprocessHandle
|
||||
})
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('attaches only to an existing stable session', async () => {
|
||||
await host.createOrAttach({
|
||||
sessionId: 'stable-pane-session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
const result = await host.createOrAttach({
|
||||
sessionId: 'stable-pane-session',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
attachOnly: true,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
expect(result.isNew).toBe(false)
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not create when an attach-only stable session is absent', async () => {
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
sessionId: 'missing-stable-pane-session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
attachOnly: true,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
).rejects.toThrow('Session not found: missing-stable-pane-session')
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ export type CreateOrAttachOptions = {
|
||||
command?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
launchAgent?: TuiAgent
|
||||
/** Missing ownership is not permission to create during stable-pane adoption. */
|
||||
attachOnly?: boolean
|
||||
/** Explicit shell the renderer asked for, forwarded to the subprocess. */
|
||||
shellOverride?: string
|
||||
terminalWindowsWslDistro?: string | null
|
||||
|
||||
@@ -68,6 +68,8 @@ export type CreateOrAttachRequest = {
|
||||
command?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
launchAgent?: TuiAgent
|
||||
/** Rejects an absent session instead of interpreting mount uncertainty as create permission. */
|
||||
attachOnly?: boolean
|
||||
/** Explicit Windows shell override selected by the user (e.g. 'wsl.exe').
|
||||
* The daemon forwards this to its subprocess spawner so each tab honors
|
||||
* the shell picked in the "+" menu or the persisted default-shell setting,
|
||||
|
||||
+980
-42
File diff suppressed because it is too large
Load Diff
+1070
-457
File diff suppressed because it is too large
Load Diff
@@ -258,6 +258,55 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches only to an existing stable session', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: 'stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true })
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create when an attach-only stable session is absent', async () => {
|
||||
await expect(
|
||||
provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'missing-stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
).rejects.toThrow('Session not found: missing-stable-pane-session')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches only to an existing numeric provider session', async () => {
|
||||
const first = await provider.spawn({ cols: 80, rows: 24 })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: first.id,
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ id: first.id, isReattach: true })
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create when a numeric attach-only provider session is absent', async () => {
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true })
|
||||
).rejects.toThrow('Session not found: 404')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a native UNC session native on a conflicting WSL reattach', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const first = await provider.spawn({
|
||||
|
||||
@@ -369,9 +369,12 @@ function cancelAllPendingLocalPtySpawns(): void {
|
||||
/**
|
||||
* Normalizes renderer session ids that should be reused for local PTY reattach.
|
||||
*/
|
||||
function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null {
|
||||
function normalizeLocalCallerSessionId(
|
||||
sessionId: string | undefined,
|
||||
allowNumeric = false
|
||||
): string | null {
|
||||
const requested = sessionId?.trim()
|
||||
if (!requested || /^\d+$/.test(requested)) {
|
||||
if (!requested || (!allowNumeric && /^\d+$/.test(requested))) {
|
||||
return null
|
||||
}
|
||||
return requested
|
||||
@@ -525,7 +528,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
* Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed.
|
||||
*/
|
||||
async spawn(args: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
const reattachId = normalizeLocalCallerSessionId(args.sessionId)
|
||||
const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true)
|
||||
if (reattachId) {
|
||||
const pendingShutdown = ptyShutdownOperations.get(reattachId)
|
||||
if (pendingShutdown) {
|
||||
@@ -536,6 +539,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
if (args.attachOnly) {
|
||||
throw new Error(`Session not found: ${args.sessionId ?? ''}`)
|
||||
}
|
||||
const id = allocatePtyId(reattachId ?? undefined)
|
||||
const incarnationId = randomUUID()
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ export type PtySpawnOptions = {
|
||||
* Existing-session attach paths must stay false so recovery checks do not
|
||||
* replace the daemon out from under a still-live PTY. */
|
||||
isNewSession?: boolean
|
||||
/** Attach the named session atomically or fail without creating a process. */
|
||||
attachOnly?: boolean
|
||||
/** Why: allows the renderer to request a specific shell for a single new
|
||||
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
|
||||
* changing the user's persistent default shell setting. Only consulted on
|
||||
|
||||
@@ -23064,6 +23064,109 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
|
||||
})
|
||||
|
||||
it('preserves host metadata when terminal.create adopts a stable pane owner', async () => {
|
||||
const adoptStablePane = vi.fn().mockResolvedValue(null)
|
||||
const spawn = vi.fn(async (opts: { adoptedStablePane?: { owner: { handle?: string } } }) =>
|
||||
opts.adoptedStablePane
|
||||
? {
|
||||
id: 'pty-stable-owner',
|
||||
isReattach: true,
|
||||
stablePaneOwner: {
|
||||
handle: opts.adoptedStablePane.owner.handle!,
|
||||
tabId: 'stable-owner-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
}
|
||||
}
|
||||
: { id: 'pty-stable-owner' }
|
||||
)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
claudeAgentTeamsMode: 'in-process' as const
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
adoptStablePane,
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const first = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'stable-owner-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Original owner',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
adoptStablePane.mockResolvedValueOnce({
|
||||
result: { id: 'pty-stable-owner', isReattach: true },
|
||||
owner: {
|
||||
handle: first.handle,
|
||||
tabId: 'stable-owner-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: 'pty-stable-owner'
|
||||
}
|
||||
})
|
||||
|
||||
const adopted = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'stable-owner-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Replacement intent',
|
||||
command: "claude 'replacement'",
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(adopted).toMatchObject({
|
||||
handle: first.handle,
|
||||
ptyId: 'pty-stable-owner',
|
||||
title: 'Original owner',
|
||||
isReattach: true
|
||||
})
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
parentTabId: 'stable-owner-tab',
|
||||
title: 'Original owner',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
])
|
||||
expect(spawn.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "claude 'replacement'",
|
||||
adoptedStablePane: expect.anything()
|
||||
})
|
||||
expect(spawn.mock.calls[1]?.[0]).not.toMatchObject({
|
||||
command: expect.stringContaining('--teammate-mode')
|
||||
})
|
||||
})
|
||||
|
||||
it('releases a stable-pane claim when creation aborts before provider spawn', async () => {
|
||||
const releaseClaim = vi.fn()
|
||||
const spawn = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
claimStablePaneCreate: vi.fn(() => releaseClaim),
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'aborted-stable-pane',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
signal: abort.signal
|
||||
})
|
||||
).rejects.toThrow('client_disconnected')
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(releaseClaim).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('publishes the hook provider session on a headless mobile tab so native chat can address the transcript', async () => {
|
||||
const paneKey = makePaneKey('claude-tab', HEADLESS_LEAF_ID)
|
||||
const providerSession = {
|
||||
|
||||
+320
-218
@@ -527,6 +527,7 @@ import type {
|
||||
IFilesystemProvider,
|
||||
IPtyProvider,
|
||||
PtyProcessInfo,
|
||||
PtySpawnResult,
|
||||
PtyTransientFact
|
||||
} from '../providers/types'
|
||||
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
|
||||
@@ -1531,6 +1532,32 @@ type HeadlessSeedMetadata = {
|
||||
}
|
||||
|
||||
type RuntimePtyController = {
|
||||
claimStablePaneCreate?(args: {
|
||||
worktreeId: string
|
||||
connectionId: string | null
|
||||
tabId: string
|
||||
leafId: string
|
||||
}): () => void
|
||||
adoptStablePane?(opts: {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
connectionId: string | null
|
||||
worktreeId: string
|
||||
preAllocatedHandle: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
}): Promise<{
|
||||
result: PtySpawnResult
|
||||
owner: {
|
||||
handle?: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
}
|
||||
materialized?: true
|
||||
} | null>
|
||||
spawn?(opts: {
|
||||
cols: number
|
||||
rows: number
|
||||
@@ -1558,10 +1585,22 @@ type RuntimePtyController = {
|
||||
agentSessionCreateOperationId?: string
|
||||
signal?: AbortSignal
|
||||
onPtySpawnCommitted?: () => void
|
||||
adoptedStablePane?: {
|
||||
result: PtySpawnResult
|
||||
owner: {
|
||||
handle?: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
}
|
||||
materialized?: true
|
||||
}
|
||||
}): Promise<{
|
||||
id: string
|
||||
incarnationId?: PtyIncarnationId
|
||||
wslDistro?: string
|
||||
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
}>
|
||||
write(ptyId: string, data: string): boolean
|
||||
@@ -15756,6 +15795,7 @@ export class OrcaRuntimeService {
|
||||
tabId: parsed?.tabId ?? record?.tabId ?? '',
|
||||
leafId: parsed?.leafId ?? record?.leafId ?? '',
|
||||
ptyId: record?.ptyId ?? null,
|
||||
connected: pty?.connected === true,
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null)
|
||||
}
|
||||
@@ -24440,236 +24480,298 @@ export class OrcaRuntimeService {
|
||||
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
|
||||
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID()
|
||||
let paneKey = makePaneKey(tabId, leafId)
|
||||
const launchToken = launchOpts.launchConfig
|
||||
? (launchOpts.launchToken ?? randomUUID())
|
||||
: undefined
|
||||
const baseEnv = {
|
||||
...launchOpts.env,
|
||||
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
|
||||
}
|
||||
const claudeAgentTeamsSourceCommand =
|
||||
launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined
|
||||
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
|
||||
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
|
||||
launchOpts.launchConfig,
|
||||
claudeAgentTeamsSourceCommand,
|
||||
claudeAgentTeamsMode
|
||||
)
|
||||
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
|
||||
command: claudeAgentTeamsSourceCommand,
|
||||
mode: effectiveClaudeAgentTeamsMode,
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
...baseEnv
|
||||
},
|
||||
createTeamEnv: (shimDir, shimBin) =>
|
||||
this.claudeAgentTeams.createLaunchEnv({
|
||||
leaderHandle: preAllocatedHandle,
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
...baseEnv
|
||||
},
|
||||
shimDir,
|
||||
shimBin
|
||||
}).env
|
||||
})
|
||||
const sequencedStartupCommand =
|
||||
agentTeamsPlan &&
|
||||
claudeAgentTeamsSourceCommand &&
|
||||
launchOpts.command &&
|
||||
claudeAgentTeamsSourceCommand !== launchOpts.command
|
||||
? agentTeamsPlan.command
|
||||
: undefined
|
||||
const effectiveLaunchConfig =
|
||||
launchOpts.launchConfig && agentTeamsPlan
|
||||
? {
|
||||
...launchOpts.launchConfig,
|
||||
agentCommand: launchOpts.launchConfig.agentCommand
|
||||
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
|
||||
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
|
||||
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
|
||||
: agentTeamsPlan.command,
|
||||
agentEnv: {
|
||||
...launchOpts.launchConfig.agentEnv,
|
||||
...agentTeamsPlan.env
|
||||
}
|
||||
}
|
||||
: launchOpts.launchConfig
|
||||
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
|
||||
// Claude Agent Teams runs. Preserve the direct Claude command separately
|
||||
// so the wrapper can exec the teammate-mode variant after setup completes.
|
||||
const env = this.buildTerminalWorkspaceEnv(
|
||||
workspace,
|
||||
{
|
||||
...baseEnv,
|
||||
...(sequencedStartupCommand
|
||||
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
|
||||
: {})
|
||||
},
|
||||
paneKey,
|
||||
tabId,
|
||||
agentTeamsPlan?.env
|
||||
)
|
||||
const terminalColorQueryReplies =
|
||||
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
command: sequencedStartupCommand
|
||||
? launchOpts.command
|
||||
: (agentTeamsPlan?.command ?? launchOpts.command),
|
||||
launchAgent: launchOpts.launchAgent,
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
env,
|
||||
envToDelete: mergeTerminalEnvDeletionKeys(
|
||||
launchOpts.envToDelete,
|
||||
agentTeamsPlan?.envToDelete
|
||||
),
|
||||
resumeProviderSession: launchOpts.resumeProviderSession,
|
||||
telemetry: launchOpts.telemetry,
|
||||
connectionId: workspace.connectionId,
|
||||
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
connectionId: workspace.connectionId,
|
||||
tabId,
|
||||
leafId,
|
||||
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
|
||||
...(launchOpts.agentSessionClaim
|
||||
? {
|
||||
agentSessionEnsure: {
|
||||
claim: launchOpts.agentSessionClaim,
|
||||
surface: {
|
||||
worktreeId: workspace.id,
|
||||
tabId,
|
||||
leafId,
|
||||
terminalHandle: preAllocatedHandle
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(launchOpts.agentSessionCreateOperationId
|
||||
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
|
||||
: {}),
|
||||
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
|
||||
...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}),
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
// Why: a headless-created pane has no renderer session writer. Persist
|
||||
// its tab/leaf binding at spawn so a later promoted window reattaches
|
||||
// the live daemon or SSH PTY instead of replacing it with a fresh one.
|
||||
// Re-check freshly: the entry-time snapshot can go stale across the
|
||||
// awaits above if the authoritative window is destroyed mid-spawn.
|
||||
...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null
|
||||
? { persistHostSessionBinding: true }
|
||||
: {})
|
||||
leafId
|
||||
})
|
||||
reportPtySpawnCommitted()
|
||||
if (result.agentSessionEnsure) {
|
||||
const canonicalSurface = result.agentSessionEnsure.owner.surface
|
||||
preAllocatedHandle = canonicalSurface.terminalHandle
|
||||
tabId = canonicalSurface.tabId
|
||||
leafId = canonicalSurface.leafId
|
||||
paneKey = makePaneKey(tabId, leafId)
|
||||
let stablePaneCreateReleased = false
|
||||
const releaseStablePaneCreate = (): void => {
|
||||
if (stablePaneCreateReleased) {
|
||||
return
|
||||
}
|
||||
stablePaneCreateReleased = true
|
||||
claimedStablePaneCreate?.()
|
||||
}
|
||||
try {
|
||||
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId, {
|
||||
tabId,
|
||||
leafId,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
})
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (launchOpts.persistHostSessionBinding) {
|
||||
pty.runtimeSessionOwned = true
|
||||
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
const launchToken = launchOpts.launchConfig
|
||||
? (launchOpts.launchToken ?? randomUUID())
|
||||
: undefined
|
||||
const baseEnv = {
|
||||
...launchOpts.env,
|
||||
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
|
||||
}
|
||||
if (launchOpts.title) {
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.title = launchOpts.title
|
||||
pty.titleUpdatedAt = observedAt
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
|
||||
} else {
|
||||
pty.title = null
|
||||
pty.titleUpdatedAt = null
|
||||
const claudeAgentTeamsSourceCommand =
|
||||
launchOpts.claudeAgentTeamsSourceCommand?.trim() ||
|
||||
launchOpts.command?.trim() ||
|
||||
undefined
|
||||
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
|
||||
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
|
||||
launchOpts.launchConfig,
|
||||
claudeAgentTeamsSourceCommand,
|
||||
claudeAgentTeamsMode
|
||||
)
|
||||
let agentTeamsPlan: Awaited<ReturnType<typeof buildClaudeAgentTeamsLaunchPlan>> | undefined
|
||||
try {
|
||||
agentTeamsPlan = adoptedBeforeLaunch
|
||||
? undefined
|
||||
: await buildClaudeAgentTeamsLaunchPlan({
|
||||
command: claudeAgentTeamsSourceCommand,
|
||||
mode: effectiveClaudeAgentTeamsMode,
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
...baseEnv
|
||||
},
|
||||
createTeamEnv: (shimDir, shimBin) =>
|
||||
this.claudeAgentTeams.createLaunchEnv({
|
||||
leaderHandle: preAllocatedHandle,
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
...baseEnv
|
||||
},
|
||||
shimDir,
|
||||
shimBin
|
||||
}).env
|
||||
})
|
||||
} catch (error) {
|
||||
releaseStablePaneCreate?.()
|
||||
throw error
|
||||
}
|
||||
pty.tabId = tabId
|
||||
pty.paneKey = paneKey
|
||||
pty.launchConfig = effectiveLaunchConfig
|
||||
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = launchToken ?? null
|
||||
pty.launchAgent = launchOpts.launchAgent ?? null
|
||||
}
|
||||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||||
if (pty && launchOpts.deferMobileSessionPublish !== true) {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
|
||||
const sequencedStartupCommand =
|
||||
agentTeamsPlan &&
|
||||
claudeAgentTeamsSourceCommand &&
|
||||
launchOpts.command &&
|
||||
claudeAgentTeamsSourceCommand !== launchOpts.command
|
||||
? agentTeamsPlan.command
|
||||
: undefined
|
||||
const effectiveLaunchConfig =
|
||||
launchOpts.launchConfig && agentTeamsPlan
|
||||
? {
|
||||
...launchOpts.launchConfig,
|
||||
agentCommand: launchOpts.launchConfig.agentCommand
|
||||
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
|
||||
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
|
||||
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
|
||||
: agentTeamsPlan.command,
|
||||
agentEnv: {
|
||||
...launchOpts.launchConfig.agentEnv,
|
||||
...agentTeamsPlan.env
|
||||
}
|
||||
}
|
||||
: launchOpts.launchConfig
|
||||
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
|
||||
// Claude Agent Teams runs. Preserve the direct Claude command separately
|
||||
// so the wrapper can exec the teammate-mode variant after setup completes.
|
||||
const env = this.buildTerminalWorkspaceEnv(
|
||||
workspace,
|
||||
{
|
||||
...baseEnv,
|
||||
...(sequencedStartupCommand
|
||||
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
|
||||
: {})
|
||||
},
|
||||
paneKey,
|
||||
tabId,
|
||||
agentTeamsPlan?.env
|
||||
)
|
||||
const terminalColorQueryReplies =
|
||||
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const persistHostSessionBinding =
|
||||
launchOpts.persistHostSessionBinding ||
|
||||
launchOpts.surfaceOwner === false ||
|
||||
this.getAvailableAuthoritativeWindow() === null
|
||||
let result: Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
|
||||
try {
|
||||
result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
command: sequencedStartupCommand
|
||||
? launchOpts.command
|
||||
: (agentTeamsPlan?.command ?? launchOpts.command),
|
||||
launchAgent: launchOpts.launchAgent,
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
env,
|
||||
envToDelete: mergeTerminalEnvDeletionKeys(
|
||||
launchOpts.envToDelete,
|
||||
agentTeamsPlan?.envToDelete
|
||||
),
|
||||
resumeProviderSession: launchOpts.resumeProviderSession,
|
||||
telemetry: launchOpts.telemetry,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId,
|
||||
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
|
||||
...(launchOpts.agentSessionClaim
|
||||
? {
|
||||
agentSessionEnsure: {
|
||||
claim: launchOpts.agentSessionClaim,
|
||||
surface: {
|
||||
worktreeId: workspace.id,
|
||||
tabId,
|
||||
leafId,
|
||||
terminalHandle: preAllocatedHandle
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(launchOpts.agentSessionCreateOperationId
|
||||
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
|
||||
: {}),
|
||||
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
|
||||
...(launchOpts.onPtySpawnCommitted
|
||||
? { onPtySpawnCommitted: reportPtySpawnCommitted }
|
||||
: {}),
|
||||
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
// Why: a headless-created pane has no renderer session writer. Persist
|
||||
// its tab/leaf binding at spawn so a later promoted window reattaches
|
||||
// the live daemon or SSH PTY instead of replacing it with a fresh one.
|
||||
// Re-check freshly: the entry-time snapshot can go stale across the
|
||||
// awaits above if the authoritative window is destroyed mid-spawn.
|
||||
...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
|
||||
})
|
||||
} finally {
|
||||
releaseStablePaneCreate?.()
|
||||
}
|
||||
if (!result.stablePaneOwner) {
|
||||
reportPtySpawnCommitted()
|
||||
}
|
||||
const adoptedStablePane = Boolean(result.stablePaneOwner)
|
||||
if (result.agentSessionEnsure) {
|
||||
const canonicalSurface = result.agentSessionEnsure.owner.surface
|
||||
preAllocatedHandle = canonicalSurface.terminalHandle
|
||||
tabId = canonicalSurface.tabId
|
||||
leafId = canonicalSurface.leafId
|
||||
paneKey = makePaneKey(tabId, leafId)
|
||||
} else if (result.stablePaneOwner) {
|
||||
preAllocatedHandle = result.stablePaneOwner.handle
|
||||
tabId = result.stablePaneOwner.tabId
|
||||
leafId = result.stablePaneOwner.leafId
|
||||
paneKey = makePaneKey(tabId, leafId)
|
||||
}
|
||||
try {
|
||||
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId, {
|
||||
tabId,
|
||||
leafId,
|
||||
title: launchOpts.title ?? null,
|
||||
activate: presentation === 'focused',
|
||||
// Why: explicit background presentation may carry legacy activate
|
||||
// metadata from an already-owned renderer pane; don't select it on mobile.
|
||||
selectIfNoActiveTab: presentation !== 'background',
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
})
|
||||
}
|
||||
let surface: RuntimeTerminalCreate['surface'] = 'background'
|
||||
let warning: string | undefined
|
||||
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
|
||||
try {
|
||||
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
|
||||
// failing here must not strand a live process without returning a handle.
|
||||
// Pass the pre-minted tabId so the renderer adopts under the same id
|
||||
// already baked into the PTY env — keeps paneKey hook attribution intact.
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
surface = 'visible'
|
||||
} catch (err) {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
warning = createTerminalRevealWarning(handle, err)
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (persistHostSessionBinding) {
|
||||
pty.runtimeSessionOwned = true
|
||||
}
|
||||
if (!adoptedStablePane) {
|
||||
if (launchOpts.title) {
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.title = launchOpts.title
|
||||
pty.titleUpdatedAt = observedAt
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
|
||||
} else {
|
||||
pty.title = null
|
||||
pty.titleUpdatedAt = null
|
||||
}
|
||||
pty.launchConfig = effectiveLaunchConfig
|
||||
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = launchToken ?? null
|
||||
pty.launchAgent = launchOpts.launchAgent ?? null
|
||||
}
|
||||
pty.tabId = tabId
|
||||
pty.paneKey = paneKey
|
||||
}
|
||||
} else if (presentation !== 'background') {
|
||||
warning = createTerminalRevealWarning(handle)
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
tabId,
|
||||
paneKey,
|
||||
ptyId: result.id,
|
||||
worktreeId: workspace.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...this.getPtyExecutionHostMetadata(result.id),
|
||||
surface,
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {}),
|
||||
...(warning ? { warning } : {})
|
||||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||||
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
|
||||
tabId,
|
||||
leafId,
|
||||
title: launchOpts.title ?? null,
|
||||
activate: presentation === 'focused',
|
||||
// Why: explicit background presentation may carry legacy activate
|
||||
// metadata from an already-owned renderer pane; don't select it on mobile.
|
||||
selectIfNoActiveTab: presentation !== 'background',
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
|
||||
})
|
||||
}
|
||||
let surface: RuntimeTerminalCreate['surface'] = 'background'
|
||||
let warning: string | undefined
|
||||
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
|
||||
try {
|
||||
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
|
||||
// failing here must not strand a live process without returning a handle.
|
||||
// Pass the pre-minted tabId so the renderer adopts under the same id
|
||||
// already baked into the PTY env — keeps paneKey hook attribution intact.
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
surface = 'visible'
|
||||
} catch (err) {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
warning = createTerminalRevealWarning(handle, err)
|
||||
}
|
||||
} else if (presentation !== 'background') {
|
||||
warning = createTerminalRevealWarning(handle)
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
tabId,
|
||||
paneKey,
|
||||
ptyId: result.id,
|
||||
worktreeId: workspace.id,
|
||||
title: pty?.title ?? launchOpts.title ?? null,
|
||||
...this.getPtyExecutionHostMetadata(result.id),
|
||||
surface,
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {}),
|
||||
...(adoptedStablePane ? { isReattach: true as const } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
} finally {
|
||||
releaseStablePaneCreate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ function resolveMockPaneWindowsShiftEnterEncoding(
|
||||
}
|
||||
|
||||
type ConnectCallbacks = {
|
||||
onReattachDetermined?: () => void
|
||||
onConnect?: () => void
|
||||
onData?: (
|
||||
data: string,
|
||||
@@ -5479,6 +5480,62 @@ describe('connectPanePty', () => {
|
||||
expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach')
|
||||
})
|
||||
|
||||
it('replays a stable-pane adoption without submitting the SSH resume command', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const stablePtyId = toAppSshPtyId('conn-1', 'stable-pane-session')
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ callbacks }) => {
|
||||
callbacks.onReattachDetermined?.()
|
||||
transport.getPtyId.mockReturnValue(stablePtyId)
|
||||
callbacks.onData?.('NEWER-LIVE-SSH-OUTPUT')
|
||||
return {
|
||||
id: stablePtyId,
|
||||
isReattach: true,
|
||||
replay: 'ORIGINAL-LIVE-SSH-OUTPUT'
|
||||
}
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]])
|
||||
}
|
||||
const pane = createPane(1)
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
startup: { command: 'codex resume provider-session' }
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(4)
|
||||
if (!onDataHandler || parseCallbacks.length === 0) {
|
||||
throw new Error('expected replay and terminal input handlers')
|
||||
}
|
||||
;(onDataHandler as (data: string) => void)('DURING_ADOPTION_REPLAY\r')
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('DURING_ADOPTION_REPLAY\r')
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(2)
|
||||
}
|
||||
;(onDataHandler as (data: string) => void)('AFTER_ADOPTION_REPLAY\r')
|
||||
|
||||
expect(pane.container.dataset.ptyId).toBe(stablePtyId)
|
||||
expect(writes.join('')).toContain('ORIGINAL-LIVE-SSH-OUTPUT')
|
||||
expect(writes.join('').indexOf('ORIGINAL-LIVE-SSH-OUTPUT')).toBeLessThan(
|
||||
writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT')
|
||||
)
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r')
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r')
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId)
|
||||
})
|
||||
|
||||
it('drops xterm onData while pane is replaying restored bytes', async () => {
|
||||
// Regression: during replay, xterm auto-replies to embedded queries (DA1/DECRQM/OSC/CPR) via onData must not reach transport.sendInput or they land as stray chars on the prompt. See replay-guard.ts.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -10961,16 +11018,22 @@ describe('connectPanePty', () => {
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the reattach snapshot before live bytes delivered during the spawn reply', async () => {
|
||||
it('drains live bytes after transport confirms an explicit reattach', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { deliverTerminalDataWithDeferredCredit } =
|
||||
await import('@/lib/pane-manager/terminal-delivery-credit')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
const acknowledgeLiveFrame = vi.fn()
|
||||
transport.connect.mockImplementation(
|
||||
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
|
||||
if (!sessionId) {
|
||||
return null
|
||||
}
|
||||
// Why: the real dispatcher drains post-snapshot bytes as soon as spawn IPC resolves, before connect() returns.
|
||||
callbacks?.onData?.('post-snapshot-live')
|
||||
callbacks?.onReattachDetermined?.()
|
||||
deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => {
|
||||
callbacks?.onData?.('post-snapshot-live')
|
||||
})
|
||||
return { id: sessionId, snapshot: 'authoritative-snapshot' }
|
||||
}
|
||||
)
|
||||
@@ -10988,13 +11051,14 @@ describe('connectPanePty', () => {
|
||||
const snapshotIndex = writes.indexOf('authoritative-snapshot')
|
||||
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(writes).not.toContain('post-snapshot-live')
|
||||
while (parseCallbacks.length > 0) {
|
||||
for (let step = 0; step < 40; step += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(2)
|
||||
}
|
||||
await flushAsyncTicks(8)
|
||||
const liveIndex = writes.indexOf('post-snapshot-live')
|
||||
expect(liveIndex).toBeGreaterThan(snapshotIndex)
|
||||
expect(acknowledgeLiveFrame).toHaveBeenCalledOnce()
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty')
|
||||
})
|
||||
|
||||
it('re-enforces follow intent after deferred reattach live output parses', async () => {
|
||||
|
||||
@@ -5167,6 +5167,7 @@ export function connectPanePty(
|
||||
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
|
||||
.then(async (spawnedPtyId) => {
|
||||
if (outputCallbacks.generation !== transportStreamGeneration) {
|
||||
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
@@ -5180,8 +5181,30 @@ export function connectPanePty(
|
||||
? spawnedPtyId
|
||||
: transport.getPtyId()
|
||||
if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) {
|
||||
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
return null
|
||||
}
|
||||
const connectResult =
|
||||
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
|
||||
? spawnedPtyId
|
||||
: null
|
||||
if (connectResult?.isReattach) {
|
||||
pendingStartupCommand = null
|
||||
const accepted = await handleReattachResult(
|
||||
connectResult,
|
||||
null,
|
||||
coldRestoreOverride,
|
||||
outputCallbacks.generation
|
||||
)
|
||||
finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (accepted && resolvedPtyId && typeof gen === 'number') {
|
||||
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
|
||||
} else if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
}
|
||||
return accepted ? resolvedPtyId : null
|
||||
}
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
@@ -5251,9 +5274,11 @@ export function connectPanePty(
|
||||
if (resolvedPtyId && connectionId) {
|
||||
schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation)
|
||||
return resolvedPtyId
|
||||
})
|
||||
.catch(async () => {
|
||||
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) {
|
||||
clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
@@ -5683,6 +5708,11 @@ export function connectPanePty(
|
||||
return {
|
||||
generation,
|
||||
callbacks: {
|
||||
onReattachDetermined: (): void => {
|
||||
if (isCurrent()) {
|
||||
beginReattachLiveDataDeferralIfUnowned(generation)
|
||||
}
|
||||
},
|
||||
onConnect: (): void => {
|
||||
if (isCurrent()) {
|
||||
reportRemoteRendererSerializerReady()
|
||||
@@ -7591,6 +7621,14 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
|
||||
const beginReattachLiveDataDeferralIfUnowned = (
|
||||
ownerGeneration = transportStreamGeneration
|
||||
): void => {
|
||||
if (!deferredReattachLiveDataOwners.has(ownerGeneration)) {
|
||||
beginReattachLiveDataDeferral(ownerGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
const finishReattachLiveDataDeferral = (
|
||||
deliver: boolean,
|
||||
acceptedGeneration = transportStreamGeneration
|
||||
|
||||
@@ -72,6 +72,8 @@ export type PtyConnectResult = {
|
||||
}
|
||||
|
||||
type PtyCallbacks = {
|
||||
/** Called before an adopted PTY can publish buffered/live bytes. */
|
||||
onReattachDetermined?: () => void
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onData?: (data: string, meta?: PtyDataMeta) => void
|
||||
|
||||
@@ -133,6 +133,41 @@ describe('createIpcPtyTransport', () => {
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('announces a daemon adoption before publishing its buffered PTY data', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
spawn.mockResolvedValueOnce({ id: 'adopted-pty', isReattach: true })
|
||||
const order: string[] = []
|
||||
const transport = createIpcPtyTransport({})
|
||||
const connecting = transport.connect({
|
||||
url: '',
|
||||
callbacks: {
|
||||
onReattachDetermined: () => order.push('adopt'),
|
||||
onData: () => order.push('data')
|
||||
}
|
||||
})
|
||||
onData?.({ id: 'adopted-pty', data: 'buffered' })
|
||||
await connecting
|
||||
|
||||
expect(order).toEqual(['adopt', 'data'])
|
||||
})
|
||||
|
||||
it('does not reannounce an explicit reattach already owned by its caller', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
spawn.mockResolvedValueOnce({ id: 'restored-pty', isReattach: true })
|
||||
const onReattachDetermined = vi.fn()
|
||||
const transport = createIpcPtyTransport({})
|
||||
|
||||
await transport.connect({
|
||||
url: '',
|
||||
sessionId: 'restored-pty',
|
||||
callbacks: { onReattachDetermined }
|
||||
})
|
||||
|
||||
expect(onReattachDetermined).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards requested environment deletions to the PTY spawn', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
@@ -2516,6 +2551,83 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
|
||||
})
|
||||
|
||||
it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => {
|
||||
runtimeCall.mockResolvedValue({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'term-original',
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
title: 'Original',
|
||||
surface: 'background',
|
||||
isReattach: true
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const onPtySpawn = vi.fn()
|
||||
const onReattachDetermined = vi.fn()
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
tabId: 'tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
onPtySpawn
|
||||
})
|
||||
|
||||
const result = await transport.connect({
|
||||
url: '',
|
||||
callbacks: { onReattachDetermined }
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'remote:env-1@@term-original',
|
||||
replay: '',
|
||||
isReattach: true
|
||||
})
|
||||
expect(onReattachDetermined).toHaveBeenCalledOnce()
|
||||
expect(onPtySpawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close an adopted stable-pane owner when create resolves after destroy', async () => {
|
||||
let resolveCreate!: (value: unknown) => void
|
||||
runtimeCall.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
)
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
tabId: 'tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
|
||||
const connecting = transport.connect({ url: '', callbacks: {} })
|
||||
transport.destroy?.()
|
||||
resolveCreate({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
handle: 'term-original',
|
||||
worktreeId: 'repo1::/remote/wt',
|
||||
title: 'Original',
|
||||
surface: 'background',
|
||||
isReattach: true
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await connecting
|
||||
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.close' })
|
||||
)
|
||||
})
|
||||
|
||||
it('suspends passive remote output until host sleep is cancelled', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { applyHostWorktreeTerminalSleepState } = await import('./pty-shutdown-exit-deferral')
|
||||
|
||||
@@ -822,6 +822,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
return spawnResult
|
||||
}
|
||||
|
||||
if (spawnResult.isReattach && !admittedSessionId) {
|
||||
storedCallbacks.onReattachDetermined?.()
|
||||
}
|
||||
ptyId = spawnResult.id
|
||||
connected = true
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
result: RemoteAgentSessionLaunchResult,
|
||||
environmentId: string
|
||||
): boolean {
|
||||
if (result.disposition !== undefined) {
|
||||
if (result.disposition !== undefined || result.terminal.isReattach === true) {
|
||||
// Why: every structured launch is host-owned; provisional teardown must
|
||||
// never close its canonical terminal while snapshot reconciliation catches up.
|
||||
return true
|
||||
@@ -1968,6 +1968,9 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
handle = createdTerminal.handle
|
||||
|
||||
if (createdTerminal.isReattach === true) {
|
||||
storedCallbacks.onReattachDetermined?.()
|
||||
}
|
||||
remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId)
|
||||
registerShutdownHandlers(remotePtyId)
|
||||
connected = true
|
||||
@@ -1975,7 +1978,9 @@ export function createRemoteRuntimePtyTransport(
|
||||
cols: options.cols ?? 80,
|
||||
rows: options.rows ?? 24
|
||||
}
|
||||
onPtySpawn?.(remotePtyId)
|
||||
if (createdTerminal.isReattach !== true) {
|
||||
onPtySpawn?.(remotePtyId)
|
||||
}
|
||||
emitRecoveryState()
|
||||
|
||||
try {
|
||||
@@ -1991,7 +1996,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
|
||||
return {
|
||||
id: remotePtyId,
|
||||
replay: ''
|
||||
replay: '',
|
||||
...(createdTerminal.isReattach === true ? { isReattach: true } : {})
|
||||
} satisfies PtyConnectResult
|
||||
} catch (error) {
|
||||
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
|
||||
|
||||
@@ -19,14 +19,27 @@ const retryByPane = new WeakMap<ManagedPane, RetryState>()
|
||||
function scheduleRetryTick(run: () => void): RetrySchedule {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
let cancelled = false
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (): void => {
|
||||
if (cancelled || settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
run()
|
||||
}
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && !settled) {
|
||||
// Why: FitAddon must observe committed CSS, and synchronous rAF test
|
||||
// shims must not recursively consume the whole retry budget inline.
|
||||
timer = setTimeout(run, LAYOUT_SETTLE_MS)
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(finish, LAYOUT_SETTLE_MS)
|
||||
}
|
||||
})
|
||||
// Why: Chromium can indefinitely throttle rAF for a hidden Electron window; the fit budget must still release deferred PTY output.
|
||||
timer = setTimeout(finish, LAYOUT_SETTLE_MS * 2)
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
|
||||
@@ -185,6 +185,19 @@ describe('safeFitAndThen unmeasurable-pane retry', () => {
|
||||
safeFit(pane)
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves failure when hidden-window animation frames are withheld', async () => {
|
||||
const pane = createPane({ rect: { width: 0, height: 0 } })
|
||||
const continuation = vi.fn()
|
||||
|
||||
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
|
||||
retryIfUnmeasurable: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(40 * 32)
|
||||
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
await expect(handle.completion).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paneFitClientSizeChanged (reveal fit gate)', () => {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"appId": "com.stablyai.orca",
|
||||
"stateSchemaVersion": 1,
|
||||
"readableStateSchemaVersions": [1],
|
||||
"daemonProtocolVersion": 30,
|
||||
"daemonProtocolVersion": 31,
|
||||
"attachableDaemonProtocolVersions": [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30
|
||||
27, 28, 29, 30, 31
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
|
||||
appId: 'com.stablyai.orca',
|
||||
stateSchemaVersion: 1,
|
||||
readableStateSchemaVersions: [1],
|
||||
daemonProtocolVersion: 30,
|
||||
daemonProtocolVersion: 31,
|
||||
attachableDaemonProtocolVersions: [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30
|
||||
27, 28, 29, 30, 31
|
||||
]
|
||||
} as const
|
||||
|
||||
@@ -659,6 +659,8 @@ export type RuntimeTerminalCreate = {
|
||||
warning?: string
|
||||
/** Present only for the structured host-authority resume path. */
|
||||
agentSessionDisposition?: 'created' | 'adopted'
|
||||
/** The host attached this request to the existing stable pane owner. */
|
||||
isReattach?: true
|
||||
}
|
||||
|
||||
export type RuntimeTerminalSplit = {
|
||||
@@ -672,6 +674,7 @@ export type RuntimeTerminalResolvePane = {
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string | null
|
||||
connected?: boolean
|
||||
worktreeId?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
hostPlatform?: NodeJS.Platform
|
||||
|
||||
Reference in New Issue
Block a user