diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 4002a44a76e6..1b62efdb21f3 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -1125,7 +1125,6 @@ 3 test-no-wall-clock:src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx 1 test-no-wall-clock:src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx 1 test-no-wall-clock:src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx -2 test-no-wall-clock:src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts 1 test-no-wall-clock:src/renderer/src/components/tab-bar/open-tab-search.test.ts 4 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts 1 test-no-wall-clock:src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index ee403fa89f41..659c97a58feb 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -5,7 +5,7 @@ 1 src/main/ipc/pty/ipc/leaf-move.ts 1 src/main/ipc/pty/pane/pane-owner-replacement.ts 3 src/main/ipc/pty/pane/stable-owner.ts -2 src/main/ipc/pty/spawn-commit-binding.ts +3 src/main/ipc/pty/spawn-commit-binding.ts # Replacement/fresh queued checks differ from recovery of an already-dispatched child. 1 src/main/ipc/renderer-shutdown-checkpoint.ts 1 src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts 1 src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts @@ -60,10 +60,10 @@ 1 src/main/runtime/runtime-workspace-session-controller.ts 2 src/main/runtime/session-graph/session-graph-promotion.ts 2 src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts -2 src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts 2 src/main/runtime/session-tabs/headless-tab-group-moves.ts 6 src/main/runtime/session-tabs/headless-tab-properties.ts 1 src/main/runtime/session-tabs/structured-session-tab-close.ts +2 src/main/runtime/session-tabs/terminal-surface-close-commit.ts 1 src/main/runtime/session-tabs/terminal-surface-retirements.ts 2 src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts 1 src/main/runtime/terminal/driver/terminal-driver-resources.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index 2ec2aab42ee8..1b002b487922 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -21,6 +21,8 @@ 3 src/renderer/src/components/worktree-jump-palette-status-inputs.ts 9 src/renderer/src/lib/agent-background-session-test-state.ts 1 src/renderer/src/lib/agent-hibernation-coordinator.ts +7 src/renderer/src/lib/agent-launch-pane-verdict-application.ts # Shipped window owner: fenced launch withdrawal/outcome; port when main owns layout. +8 src/renderer/src/lib/conversation-switch-projection.ts # Shipped window owner: fenced retirement/restoration; port when main owns layout. 4 src/renderer/src/lib/floating-workspace-terminal-actions.ts 1 src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts 4 src/renderer/src/lib/session-write-subscriber.ts diff --git a/config/scripts/check-e2e-typecheck-ratchet.mjs b/config/scripts/check-e2e-typecheck-ratchet.mjs index 1f43e5c6da0d..f1991c72fcaa 100644 --- a/config/scripts/check-e2e-typecheck-ratchet.mjs +++ b/config/scripts/check-e2e-typecheck-ratchet.mjs @@ -53,6 +53,7 @@ export function main( console.error(`${file}: ${now} type error(s), baseline allows ${allowed}.`) } if (grown.length > 0) { + console.error(tscOutput) console.error(`See the errors with: pnpm typecheck:e2e`) } for (const { file, now, allowed } of shrunk) { diff --git a/config/scripts/check-layout-writer-ratchets.mjs b/config/scripts/check-layout-writer-ratchets.mjs index b47bc51145ce..7bcc9ceacd30 100644 --- a/config/scripts/check-layout-writer-ratchets.mjs +++ b/config/scripts/check-layout-writer-ratchets.mjs @@ -320,6 +320,27 @@ function isSameNameCopy(property, name) { return ts.isPropertyAccessExpression(value) && value.name.text === name } +/** Only the property-name argument of the standard presence read is a read. */ +function isOwnPresenceRead(node) { + const call = node.parent + if ( + !ts.isCallExpression(call) || + call.questionDotToken || + call.arguments.length !== 2 || + call.arguments[1] !== node + ) { + return false + } + const callee = call.expression + return ( + ts.isPropertyAccessExpression(callee) && + !callee.questionDotToken && + callee.name.text === 'hasOwn' && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' + ) +} + /** Layout writes in one file under one ratchet's rules. */ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { const names = [...ratchet.writerNames, ...ratchet.assignedFields] @@ -381,18 +402,8 @@ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { !(ts.isPropertyAssignment(node.parent) && node.parent.name === node) && !ts.isComputedPropertyName(node.parent) && !(ts.isElementAccessExpression(node.parent) && node.parent.argumentExpression === node) && - !( - ts.isCallExpression(node.parent) && - !node.parent.questionDotToken && - node.parent.arguments.length === 2 && - node.parent.arguments[1] === node && - ts.isPropertyAccessExpression(node.parent.expression) && - !node.parent.expression.questionDotToken && - ts.isIdentifier(node.parent.expression.expression) && - node.parent.expression.expression.text === 'Object' && - node.parent.expression.name.text === 'hasOwn' - ) && - !ts.isLiteralTypeNode(node.parent) + !ts.isLiteralTypeNode(node.parent) && + !isOwnPresenceRead(node) ) { count += 1 } diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 8802da83ed65..a3192e6bbf7a 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -10,7 +10,8 @@ export const UNIT_INCLUDE = [ 'config/scripts/**/*.test.ts', 'config/scripts/**/*.test.mjs', 'tests/tools/**/*.test.mjs', - 'tests/e2e/**/*.unit.test.ts' + 'tests/e2e/**/*.unit.test.ts', + 'tests/e2e/**/*.unit.test.tsx' ] export const UNIT_SETUP_FILES = [ diff --git a/config/scripts/locale-cross-locale-key-overrides.mjs b/config/scripts/locale-cross-locale-key-overrides.mjs index bd649ac99f18..07d288b8656e 100644 --- a/config/scripts/locale-cross-locale-key-overrides.mjs +++ b/config/scripts/locale-cross-locale-key-overrides.mjs @@ -1,4 +1,54 @@ export const CROSS_LOCALE_KEY_OVERRIDES = { + 'components.native-chat.resumeInNewChat.exitUnconfirmedFromTerminal': { + de: 'Orca konnte nicht bestätigen, dass {{agent}} in diesem Terminal beendet wurde. Beende {{agent}} hier und versuche diese Aktion erneut.', + es: 'Orca no pudo confirmar que {{agent}} se detuvo en esta terminal. Sal de {{agent}} aquí y vuelve a intentar esta acción.', + fr: 'Orca n’a pas pu confirmer l’arrêt de {{agent}} dans ce terminal. Quittez {{agent}} ici, puis réessayez cette action.', + ja: 'Orca は、このターミナルで {{agent}} が停止したことを確認できませんでした。ここで {{agent}} を終了してから、この操作をもう一度試してください。', + ko: 'Orca가 이 터미널에서 {{agent}}이(가) 중지되었는지 확인할 수 없습니다. 여기서 {{agent}}을(를) 종료한 다음 이 작업을 다시 시도하세요.', + pt: 'O Orca não pôde confirmar que {{agent}} parou neste terminal. Encerre {{agent}} aqui e tente esta ação novamente.', + ru: 'Orca не удалось подтвердить, что {{agent}} остановлен в этом терминале. Завершите {{agent}} здесь и повторите это действие.', + tr: 'Orca, {{agent}} uygulamasının bu terminalde durduğunu doğrulayamadı. Burada {{agent}} uygulamasından çıkın ve bu işlemi yeniden deneyin.', + zh: 'Orca 无法确认 {{agent}} 已在此终端中停止。请在此处退出 {{agent}},然后重试此操作。', + 'zh-Hant': + 'Orca 無法確認 {{agent}} 已在此終端機中停止。請在此處結束 {{agent}},然後重試此操作。' + }, + 'components.native-chat.resumeInNewChat.exitUnconfirmed': { + de: 'Orca konnte nicht bestätigen, dass {{agent}} beendet wurde. Beende {{agent}} in seinem Terminal und versuche diese Aktion erneut.', + es: 'Orca no pudo confirmar que {{agent}} se detuvo. Sal de {{agent}} en su terminal y vuelve a intentar esta acción.', + fr: 'Orca n’a pas pu confirmer l’arrêt de {{agent}}. Quittez {{agent}} dans son terminal, puis réessayez cette action.', + ja: 'Orca は {{agent}} が停止したことを確認できませんでした。{{agent}} を実行中のターミナルで終了してから、この操作をもう一度試してください。', + ko: 'Orca가 {{agent}}이(가) 중지되었는지 확인할 수 없습니다. 해당 터미널에서 {{agent}}을(를) 종료한 다음 이 작업을 다시 시도하세요.', + pt: 'O Orca não pôde confirmar que {{agent}} parou. Encerre {{agent}} no terminal em que está sendo executado e tente esta ação novamente.', + ru: 'Orca не удалось подтвердить, что {{agent}} остановлен. Завершите {{agent}} в его терминале и повторите это действие.', + tr: 'Orca, {{agent}} uygulamasının durduğunu doğrulayamadı. {{agent}} uygulamasından çalıştığı terminalde çıkın ve bu işlemi yeniden deneyin.', + zh: 'Orca 无法确认 {{agent}} 已停止。请在其所在的终端中退出 {{agent}},然后重试此操作。', + 'zh-Hant': + 'Orca 無法確認 {{agent}} 已停止。請在其所在的終端機中結束 {{agent}},然後重試此操作。' + }, + 'components.tab.bar.SortableTabContextMenu.showAsTerminal': { + de: 'Als Terminal anzeigen', + es: 'Mostrar como terminal', + fr: 'Afficher en terminal', + ja: 'ターミナルとして表示', + ko: '터미널로 표시', + pt: 'Mostrar como terminal', + ru: 'Показать как терминал', + tr: 'Terminal olarak göster', + zh: '以终端显示', + 'zh-Hant': '以終端機顯示' + }, + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView': { + de: 'Mit Terminal-Agenten fortfahren', + es: 'Continuar con el agente de terminal', + fr: 'Continuer avec l’agent de terminal', + ja: 'ターミナル Agent で続ける', + ko: '터미널 에이전트로 계속하기', + pt: 'Continuar com o agente de terminal', + ru: 'Продолжить с агентом в терминале', + tr: 'Terminal ajanıyla devam et', + zh: '使用终端智能体继续', + 'zh-Hant': '使用終端機代理程式繼續' + }, // The live status describes ongoing reasoning; the composer option names the feature. 'components.native-chat.status.thinking': { zh: '思考中', diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 6b50ca830ce0..4212a8bbbc8a 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -22,6 +22,7 @@ const unitHarnesses = [ 'src/renderer/src/runtime/sync-runtime-graph-test-harness.ts' ] const supportBridges = new Set([ + 'src/renderer/src/components/tab-bar/tab-session-history-fixtures.test-fixture.ts', 'src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts', 'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts', // Autosave suites' store rig; it takes the editor harness's tab-layout maps. diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 94103e0dd7f3..c059c7df95f4 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -40,6 +40,7 @@ const NODE_RUNTIME_INCLUDE = [ // Asserts an observer survives a forced collection; conservative retention would hide a lost one. 'config/scripts/happy-dom-mutation-observer-retention.test.ts', // These reach Node SQLite through a shared helper or a dynamic import, which the scan skips. + 'tests/e2e/mobile-terminal-session-switch.unit.test.ts', 'src/main/claude/claude-transcript-catch-up.test.ts', 'src/main/claude/claude-transcript-duration.test.ts', 'tests/e2e/native-chat-startup-option-pickers.unit.test.ts', diff --git a/src/main/acp/acp-agent-connection.test.ts b/src/main/acp/acp-agent-connection.test.ts index 7e0bccfb3f69..97791677f0b7 100644 --- a/src/main/acp/acp-agent-connection.test.ts +++ b/src/main/acp/acp-agent-connection.test.ts @@ -333,7 +333,16 @@ describe('ACP process-owning connection', () => { it('settles a failed spawn but requires processless close evidence before reporting exit', async () => { const onExit = vi.fn() - const { connection, child, agent } = fixture({ onExit }, { pid: null, exitOnEnd: false }) + const physical = vi.fn<(exited: Promise) => void>() + const { connection, child, agent } = fixture( + { onExit, onPhysicalExit: physical }, + { pid: null, exitOnEnd: false } + ) + let exited = false + expect(physical).toHaveBeenCalledOnce() + void physical.mock.calls[0]![0].then(() => { + exited = true + }) agent.on('initialize', () => {}) const rejected = expect(connection.initialize()).rejects.toThrow('ENOENT') child.emit('error', new Error('ENOENT')) @@ -341,7 +350,10 @@ describe('ACP process-owning connection', () => { await rejected expect(connection.rootVerdict).toBe('unverifiable') expect(onExit).not.toHaveBeenCalled() + expect(exited).toBe(false) child.emit('close', null, null) + await physical.mock.calls[0]![0] + expect(exited).toBe(true) expect(connection.rootVerdict).toBe('exited') expect(onExit.mock.calls[0][1].exit.processless).toBe(true) expect(await connection.close()).toBe(true) @@ -420,7 +432,13 @@ it.each([true, false])( 'runs setup through the retained launch gate with initial kill delivery=%s', async (initialDelivery) => { vi.useFakeTimers() - const { connection, child } = fixture({}, { exitOnEnd: false }) + const physical = vi.fn<(exited: Promise) => void>() + const { connection, child } = fixture({ onPhysicalExit: physical }, { exitOnEnd: false }) + let exited = false + expect(physical).toHaveBeenCalledOnce() + void physical.mock.calls[0]![0].then(() => { + exited = true + }) let permitted = initialDelivery let writerGone = false const deliver = () => { @@ -488,5 +506,9 @@ it.each([true, false])( root: 'live', killDelivered: true }) + expect(exited).toBe(false) + child.emit('exit', 0, null) + await physical.mock.calls[0]![0] + expect(exited).toBe(true) } ) diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index 516ce945d91c..caf32a151d88 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -30,6 +30,7 @@ export type AcpAgentConnectionOptions = Omit & /** Process exit evidence, including expected closes and processless spawn failures. */ onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void onOutput?: () => void + onPhysicalExit?: (exited: Promise) => void signIn?: AcpSignInWatch } @@ -86,6 +87,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { } : {}) }) + options.onPhysicalExit?.(managed.exitPromise) const lifecycle: { closing: boolean; error?: Error } = { closing: false } const diagnose = (message: string): void => { try { diff --git a/src/main/acp/acp-chat-worker-error-escalation.test.ts b/src/main/acp/acp-chat-worker-error-escalation.test.ts index 32b32d8b123f..d8867c9bb300 100644 --- a/src/main/acp/acp-chat-worker-error-escalation.test.ts +++ b/src/main/acp/acp-chat-worker-error-escalation.test.ts @@ -230,10 +230,10 @@ it.each([100, 1000])( r.allow(2) // A's generic diagnostic/end commit; the unrelated prefix still blocks its answer. await vi.advanceTimersByTimeAsync(250) await r.rig.settle() - expect(r.failure).toHaveBeenCalledTimes(1) - expect(r.mail()).toHaveLength(0) // The escalation's first check follows real async work; await it instead of a fixed number of turns. await firstCheck.promise + expect(r.failure).toHaveBeenCalledTimes(1) + expect(r.mail()).toHaveLength(0) expect(samples).toHaveLength(1) otherReads = 0 for (clock = 50; clock <= 2000; clock += 50) { diff --git a/src/main/acp/acp-detached-cleanup.test.ts b/src/main/acp/acp-detached-cleanup.test.ts index ed9317737702..9616e9b4e246 100644 --- a/src/main/acp/acp-detached-cleanup.test.ts +++ b/src/main/acp/acp-detached-cleanup.test.ts @@ -29,7 +29,13 @@ it.each(['delivered', 'root-exited', 'unproven', 'error'] as const)( vi.spyOn(rig.rig.eventSink, 'tryAppendTransition').mockImplementation((transition) => refuse ? { accepted: false, reason: 'closed' } : append(transition) ) - await rig.acquire() + const physical = vi.fn<(exited: Promise) => void>() + let exited = false + await rig.acquire({ onPhysicalExit: physical }) + expect(physical).toHaveBeenCalledOnce() + void physical.mock.calls[0]![0].then(() => { + exited = true + }) const child = rig.child() const terminal = outcome === 'delivered' || outcome === 'root-exited' Object.defineProperty(child, 'lastCloseResult', { @@ -87,6 +93,7 @@ it.each(['delivered', 'root-exited', 'unproven', 'error'] as const)( expect(rig.ended).toHaveLength(terminal ? 1 : 0) if (outcome === 'delivered') { expect(rig.ended[0]).toMatchObject({ deathEvidenceKind: 'kill-delivered' }) + expect(exited).toBe(false) } if (terminal) { await rig.acquire({ fence: 2 }) @@ -96,6 +103,9 @@ it.each(['delivered', 'root-exited', 'unproven', 'error'] as const)( child.exit() return true } + child.exit() + await Promise.resolve() + expect(exited).toBe(true) await rig.adapter.closeAll() } } diff --git a/src/main/acp/acp-scripted-connection.test-fixture.ts b/src/main/acp/acp-scripted-connection.test-fixture.ts index 05f15498a1fd..460634482d2b 100644 --- a/src/main/acp/acp-scripted-connection.test-fixture.ts +++ b/src/main/acp/acp-scripted-connection.test-fixture.ts @@ -23,6 +23,7 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn private closing = false private lostWith: Error | undefined private exitedWith: ProviderProcessExit | null = null + private readonly physical = Promise.withResolvers() closes = 0 constructor( @@ -38,6 +39,7 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn }) lifecycle.child = this this.agent = agent + connectionOptions.onPhysicalExit?.(this.physical.promise) } get exited() { @@ -78,6 +80,7 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn return } this.exitedWith = status + this.physical.resolve() // As the real connection: a spawn ENOENT marks the exit error with what it proved absent. const error = withProviderSpawnAbsence( this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`), diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 347b21805f3b..d4d286dc5a0e 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -104,6 +104,7 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, + onPhysicalExit: acquire.onPhysicalExit, ...(spec.signIn ? { signIn: spec.signIn } : {}), onPermission: (request, context) => { if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e79111e348e0..d6fdb3174290 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -62,6 +62,7 @@ export type AcpAdapterRig = { acquire(options?: { fence?: number onSpawned?: () => Promise + onPhysicalExit?: (exited: Promise) => void signal?: AbortSignal waitForStart?: boolean options?: Readonly> @@ -201,6 +202,7 @@ export async function openAcpAdapterRig( fence: acquireOptions.fence ?? 1, spawnToken: 'spawn-1', events: rig.eventSink, + onPhysicalExit: acquireOptions.onPhysicalExit, ...(acquireOptions.options ? { options: acquireOptions.options } : {}), ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), ...(acquireOptions.permissionMode ? { permissionMode: acquireOptions.permissionMode } : {}), diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts index 620de9b4f89e..1e0b9bde6a2b 100644 --- a/src/main/agent-hooks/server-launch-resume-identity.test.ts +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { assertCliConversationWritersExited } from '../runtime/structured-agent-cli-writers' import { AgentHookServer, _internals } from './server' import { buildBody, postHookEvent, PANE } from './server.test-fixtures' @@ -129,7 +130,7 @@ describe('a resume launch seeds the pane identity', () => { }) }) - it('keeps the seeded identity across a host restart', async () => { + it('keeps the no-prompt launch identity across restart and refuses idle Codex until its shell is alone', async () => { const server = await startServer() server.recordLaunchResumeIdentity({ paneKey: PANE, @@ -148,6 +149,28 @@ describe('a resume launch seeds the pane identity', () => { providerSessionOnly: true }) ]) + let shellAlone = false + const writers = { + rows: () => restarted.getStatusSnapshotForPane(PANE), + saved: () => [], + observation: (row: Parameters[0]) => + restarted.readCliWriterObservation(row), + terminal: () => ({ + pane: { ptyId: 'shell', incarnationId: 'shell-1', identity: 'shell:shell-1' } + }), + links: () => [], + sleeping: () => false, + shellAlone: async () => shellAlone + } + await expect( + assertCliConversationWritersExited(writers, 'codex', RESUMED.id) + ).rejects.toMatchObject({ + refusal: { details: { reason: 'conversationHeldElsewhere' } } + }) + shellAlone = true + await expect( + assertCliConversationWritersExited(writers, 'codex', RESUMED.id) + ).resolves.toBeUndefined() }) it('ignores a pane key that is not a stable tab:leaf key', async () => { diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 6b69411c9f92..ff3f50d46c56 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -1,9 +1,9 @@ -/** Use process-local launch state; after restart, ordinary pane attachment handles recovery. */ - import type { AgentLaunchPaneVerdict } from '../../shared/agent-launch-pane-verdict' import { parsePaneKey } from '../../shared/stable-pane-id' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +/** Use process-local launch state; after restart, ordinary pane attachment handles recovery. */ + const PROCEED: AgentLaunchPaneVerdict = { kind: 'proceed' } export type AgentLaunchPane = { worktreeId: string; paneKey: string } @@ -14,12 +14,13 @@ type RunningLaunch = { settled: Promise finished: Promise closedByUser: boolean + superseded: boolean } const runningLaunchesByPane = new Map() const failedLaunchesByPane = new Map< string, - { pane: AgentLaunchPane; verdict: AgentLaunchPaneVerdict } + { pane: AgentLaunchPane; verdict: AgentLaunchPaneVerdict; running?: RunningLaunch } >() function paneKeyOf(pane: AgentLaunchPane): string { @@ -30,6 +31,7 @@ export type RunningAgentLaunchPane = { /** Attach as soon as the agent binds, without waiting for prompt delivery. */ agentBound(): void finish(verdict: AgentLaunchPaneVerdict): void + current(): boolean closedByUser(): boolean } @@ -46,8 +48,10 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent finished: new Promise((done) => { resolve = done }), + superseded: false, closedByUser: false } + noteAgentLaunchPaneAccepted(pane.paneKey) runningLaunchesByPane.set(key, running) failedLaunchesByPane.delete(key) let finished = false @@ -62,18 +66,38 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent runningLaunchesByPane.delete(key) if ( !running.closedByUser && - (verdict.kind === 'not-started' || verdict.kind === 'unconfirmed') + (verdict.kind === 'not-started' || + verdict.kind === 'unconfirmed' || + verdict.kind === 'withdrawn') ) { - failedLaunchesByPane.set(key, { pane, verdict }) + failedLaunchesByPane.set(key, { pane, verdict, running }) } } settle(verdict) resolve(verdict) }, + current: () => + !running.superseded && + !running.closedByUser && + (runningLaunchesByPane.get(key) ?? failedLaunchesByPane.get(key)?.running) === running, closedByUser: () => running.closedByUser } } +/** Acceptance precedes option resolution, including callers that do not publish an early tab. */ +export function noteAgentLaunchPaneAccepted(paneKey: string): void { + for (const running of runningLaunchesByPane.values()) { + if (running.pane.paneKey === paneKey) { + running.superseded = true + } + } + for (const failed of failedLaunchesByPane.values()) { + if (failed.pane.paneKey === paneKey && failed.running) { + failed.running.superseded = true + } + } +} + /** `null` targets every pane of the workspace. */ function launchesIn( launches: Map, diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts index 0caa3a3038c3..29253efee299 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -1,3 +1,4 @@ +import { isNativeConversationMessage } from '../../shared/agent-session-native-content' import { stat } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -24,6 +25,33 @@ import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +export function nativeConversationContent( + snapshot: Pick +): 'saved' | 'empty' | 'uncertain' { + const submissions = new Map( + snapshot.submissions.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + submission + ]) + ) + const hasContent = snapshot.items.some(({ itemId, body }) => { + if (!isNativeConversationMessage(body)) { + return false + } + const identity = parseAgentJournalItemKey(itemId) + const submission = submissions.get(itemId) + if (submission?.dispatchState === 'accepted' || (identity && identity.provider !== 'orca')) { + return true + } + return false + }) + return hasContent + ? 'saved' + : snapshot.items.length === 0 && snapshot.submissions.length === 0 + ? 'empty' + : 'uncertain' +} + function assertNativeConversationContent(snapshot: AgentJournalSnapshot): void { const submissions = new Map( snapshot.submissions.map((submission) => [ @@ -72,14 +100,15 @@ export function isStructuredChatCliForkSourceCurrent( ) } -export async function prepareStructuredChatCliFork( - args: AiVaultPrepareChatForkArgs, - prepare: AiVaultSessionResumePreparation -): Promise { +/** The chat's current conversation on this host, as its CLI would open it; `fork` keeps its copy rules. */ +export async function resolveStructuredChatCliSource( + sourceChat: AiVaultPrepareChatForkArgs['sourceChat'], + fork: boolean +) { const host = getStructuredAgentSessionHost() const record = - host && host.deps.store.selectWritableRecord(args.sourceChat.sessionId).kind !== 'missing' - ? host.deps.store.requireWritableRecord(args.sourceChat.sessionId) + host && host.deps.store.selectWritableRecord(sourceChat.sessionId).kind !== 'missing' + ? host.deps.store.requireWritableRecord(sourceChat.sessionId) : undefined const metadata = record && structuredAgentRuntimeMetadata(record.provider) const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain) @@ -88,13 +117,13 @@ export async function prepareStructuredChatCliFork( !record || !metadata || !head || - record.location.workspaceId !== args.sourceChat.workspaceId || + record.location.workspaceId !== sourceChat.workspaceId || record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || record.location.wslDistro !== null || // A typed locator has no CODEX_HOME path; forking would silently use the default account. (record.provider === 'codex' && !isLegacyAgentSessionAccountHome(record.accountHome)) || !isAiVaultAgent(record.provider) || - !agentForksConversations(record.provider) + (fork && !agentForksConversations(record.provider)) ) { throw new Error('This chat has no saved conversation available on this host.') } @@ -128,8 +157,18 @@ export async function prepareStructuredChatCliFork( throw new Error('This chat has no saved conversation to copy.') } assertNativeConversationContent(await host.journalSnapshot(record.sessionId)) + return { host, record, metadata, head, agent, cwd, providerSessionId, requireCurrent } +} + +/** Prepares the record's current conversation on its execution host, without a History inventory. */ +export async function prepareStructuredChatCliFork( + args: AiVaultPrepareChatForkArgs, + prepare: AiVaultSessionResumePreparation +): Promise { + const source = await resolveStructuredChatCliSource(args.sourceChat, true) + const { record, metadata, agent, cwd, providerSessionId } = source if (agentForksThroughChat(agent)) { - requireCurrent() + source.requireCurrent() const result = await copyStructuredChatRecordConversation(record, providerSessionId) const copy = result.forkedSession if (!copy || (agent === 'omp' && !copy.filePath)) { @@ -148,6 +187,17 @@ export async function prepareStructuredChatCliFork( } } } + return prepareStructuredChatCliTranscript(source, prepare, true) +} + +/** The transcript a terminal resume (or `fork`) opens, under the account home the host prepared. + * A same-id resume refuses a substituted home: another home is another conversation. */ +export async function prepareStructuredChatCliTranscript( + source: Awaited>, + prepare: AiVaultSessionResumePreparation, + fork: boolean +): Promise { + const { host, record, metadata, head, agent, cwd, providerSessionId } = source const importer = metadata.transcriptImport if (!importer) { throw new Error('This chat cannot be opened in the CLI.') @@ -180,17 +230,20 @@ export async function prepareStructuredChatCliFork( agent === 'codex' && isLegacyAgentSessionAccountHome(record.accountHome) ? record.accountHome.path : null - requireCurrent() + source.requireCurrent() const result = await prepare({ agent, sessionId: providerSessionId, filePath, codexHome, executionHostId: LOCAL_EXECUTION_HOST_ID, - fork: true + ...(fork ? { fork: true } : {}) }) const preparedHome = result.useRealCodexHome ? null : (result.substituteCodexHome ?? codexHome) if (codexHome && (result.useRealCodexHome || result.substituteCodexHome)) { + if (!fork) { + throw new Error('This conversation is saved under a different Codex account here.') + } // Codex canonicalizes CODEX_HOME, so Windows transcripts can carry a \\?\ prefix (#27200). const relativePath = codexDatedRolloutRelativePath(join(codexHome, 'sessions'), filePath) if (!relativePath) { diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 34c9f80856c1..046d902e1b2f 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -33,7 +33,8 @@ function definedEnv(env: Record): Record) => void ): ClaudeCodeProcessSpawn { let managed: ManagedProviderProcess | null = null return { @@ -58,6 +59,7 @@ export function createClaudeCodeProcessSpawn( acceptClose: claudeChildCloseProven } ) + onPhysicalExit?.(managed.exitPromise) // The SDK drains stderr only for its own local spawn; the managed process drains it here. return managed.child }, diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 692659d1f9ed..b328036d1862 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -60,6 +60,7 @@ function withoutKeys(env: Record, keys: readonly string[]): Reco } export type ClaudeStreamJsonConnectionHandlers = { + onPhysicalExit?: (exited: Promise) => void onMessage?: (message: Record) => void /** The SDK handles request IDs, aborts and deduplication; cannot combine with permissionPromptToolName. */ canUseTool?: CanUseTool @@ -99,7 +100,12 @@ export async function openClaudeStreamJsonConnection( queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() - const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, launch) + const spawner = createClaudeCodeProcessSpawn( + spawnImpl, + process.platform, + launch, + handlers.onPhysicalExit + ) // Always supply scrubbed env to prevent ambient auth leaks; only launch.env may pin CLAUDE_CONFIG_DIR. const env = await withoutRejectedClaudeApiKey( withoutKeys( diff --git a/src/main/claude/structured/claude-structured-session-acquisition.ts b/src/main/claude/structured/claude-structured-session-acquisition.ts index 02c70504f0c5..e865711a07fe 100644 --- a/src/main/claude/structured/claude-structured-session-acquisition.ts +++ b/src/main/claude/structured/claude-structured-session-acquisition.ts @@ -202,6 +202,7 @@ export async function acquireClaudeSession({ }, { onMessage, + onPhysicalExit: input.onPhysicalExit, canUseTool, onUserDialog, onFault: (error) => { diff --git a/src/main/codex/app-server/codex-app-server-connection-types.ts b/src/main/codex/app-server/codex-app-server-connection-types.ts index e2bfbec67042..bdcd08b9f031 100644 --- a/src/main/codex/app-server/codex-app-server-connection-types.ts +++ b/src/main/codex/app-server/codex-app-server-connection-types.ts @@ -5,6 +5,7 @@ export type CodexAppServerServerRequest = { } export type CodexAppServerConnectionHandlers = { + onPhysicalExit?: (exited: Promise) => void onNotification?: (method: string, params: unknown) => void onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 921daac89b31..0ebd0529a28d 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -38,6 +38,7 @@ async function connectCodexAppServer( site: 'codex-app-server-teardown' }) const { child } = managed + handlers.onPhysicalExit?.(managed.exitPromise) const tree = createClaudeChildTreeReaper(child, { exited: () => managed.rootVerdict === 'exited', supervised: managed.supervised diff --git a/src/main/codex/codex-history-journal-view.ts b/src/main/codex/codex-history-journal-view.ts index 88e902d87c9a..3a8faf7c938d 100644 --- a/src/main/codex/codex-history-journal-view.ts +++ b/src/main/codex/codex-history-journal-view.ts @@ -57,7 +57,20 @@ export function readCodexHistoryJournalView( identity.sessionId === threadId && (body.kind === 'message' || body.kind === 'tool-call') ) { - hasLegacy = true + const scope = attribution.turnScope + const owner = scope?.kind === 'turn' ? journal.item(scope.turnItemId) : null + const ownerIdentity = owner && parseAgentJournalItemKey(owner.itemId) + const ownerTurn = owner && readAgentJournalTurn(owner.body) + // Positive per-turn imports need no blanket legacy timestamp for the whole journal. + const covered = + ownerIdentity?.provider === 'legacy' && + ownerIdentity.agent === 'codex' && + ownerIdentity.sessionId === threadId && + ownerIdentity.recordId === `${TURN_LIFECYCLE_RECORD_PREFIX}${ownerTurn?.turnId}` && + (ownerTurn?.state === 'completed' || ownerTurn?.state === 'interrupted') + if (!covered) { + hasLegacy = true + } } if (identity?.provider === 'orca' && body.kind === 'message' && body.role === 'user') { nativePromptKeys.add(itemId) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index c7cffcafe007..cb5bbccb2592 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -4,6 +4,7 @@ import { isAgentSessionProviderHandleInNamespace } from '../../shared/agent-session-provider-handle-encoding' import { + AgentSessionPreSpawnError, launchPreparationFailure, type AgentSessionAcquisition, type StructuredAgentSessionAcquireInput @@ -124,6 +125,11 @@ export async function acquireCodexStructuredSession(input: { } acquisitions.assertCurrent(sessionId, attempt) account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined + try { + acquireInput.checkBeforeSpawn?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } const connection = await open( { command: launch.command, @@ -157,6 +163,7 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, + onPhysicalExit: acquireInput.onPhysicalExit, onExit: (error, exit) => { acquisition.appliedSettings.end() try { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 22495a18e3e5..8efa1a282c4e 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -1,202 +1,23 @@ -import type { Socket } from 'node:net' -import { readDaemonClientToken } from './daemon-client-token' -import { randomUUID } from 'node:crypto' +import { DaemonClientConnection } from './daemon-client-connection' import { encodeNdjson } from './ndjson' -import { - PROTOCOL_VERSION, - NOTIFY_PREFIX, - DaemonConnectionLostError, - DaemonProtocolError, - type DaemonEndpointIdentity -} from './types' +import { NOTIFY_PREFIX, DaemonConnectionLostError } from './types' import { writeRefused, type WriteSettlement } from '../../shared/pty-write-settlement' -import { - armDaemonSocketCloseHandlers, - connectDaemonSocket, - waitForDaemonConnectionAttempt -} from './daemon-client-socket-connect' -import { DaemonClientListeners } from './daemon-client-listener-registry' -import { sameDaemonIdentity, sendDaemonHello } from './daemon-client-hello-handshake' -import { DaemonPendingRequests } from './daemon-client-pending-requests' -import { - attachControlResponseReader, - attachStreamEventReader -} from './daemon-client-ndjson-readers' import { writeNotifyWithSettlement } from './daemon-client-notify-settlement' import { requestDaemonRpc } from './daemon-client-rpc-request' +export type { DaemonClientOptions } from './daemon-client-connection' -const CONNECT_TIMEOUT_MS = 5000 -const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 const REQUEST_TIMEOUT_MS = 30000 const NOTIFY_SETTLEMENT_TIMEOUT_MS = 5000 -export type DaemonClientOptions = { - socketPath: string - tokenPath: string - protocolVersion?: number -} - -export class DaemonClient { - private socketPath: string - private tokenPath: string - private protocolVersion: number - private clientId = randomUUID() - - private controlSocket: Socket | null = null - private streamSocket: Socket | null = null - private connected = false - private disconnectArmed = false - // Ignore old-socket close events after reconnect so they cannot tear down the new connection. - private connectionGeneration = 0 - // Share connection attempts across concurrent pane spawns. - private connectingPromise: Promise | null = null - private connectionAttemptGeneration = 0 - private daemonIdentity: DaemonEndpointIdentity | null = null - private daemonResizeModel: string | null | undefined - private observedAuthenticatedDisconnect = false - - private pendingRequests = new DaemonPendingRequests() - private eventListeners = new DaemonClientListeners<(event: unknown) => void>() - private disconnectedListeners = new DaemonClientListeners<() => void>() +export class DaemonClient extends DaemonClientConnection { private requestCounter = 0 - private cleanupSocketListeners: (() => void) | null = null - - constructor(opts: DaemonClientOptions) { - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - } - - isConnected(): boolean { - return this.connected - } - - getDaemonIdentity(): DaemonEndpointIdentity | null { - return this.daemonIdentity ? { ...this.daemonIdentity } : null - } - - /** - * The last daemon's resize model, kept through a disconnect (the next hello replaces it): null is - * an older daemon, undefined that no daemon has answered yet. - */ - getDaemonResizeModel(): string | null | undefined { - return this.daemonResizeModel - } - - hasObservedAuthenticatedDisconnect(): boolean { - return this.observedAuthenticatedDisconnect - } - - async ensureConnected(): Promise { - return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false) - } - - async ensureConnectedWithin(timeoutMs: number): Promise { - return this.ensureConnectedWithTimeout(timeoutMs, true) - } - - private async ensureConnectedWithTimeout( - timeoutMs: number, - sharedBudget: boolean - ): Promise { - if (this.connected) { - return - } - if (this.connectingPromise) { - // Teardown shares one deadline; normal connects allow separate socket and handshake budgets. - const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS - return waitForDaemonConnectionAttempt(this.connectingPromise, waiterTimeoutMs) - } - - const attemptGeneration = this.connectionAttemptGeneration - this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget) - try { - await this.connectingPromise - } finally { - this.connectingPromise = null - } - } - - private async doConnect( - timeoutMs: number, - attemptGeneration: number, - sharedBudget: boolean - ): Promise { - const token = readDaemonClientToken(this.tokenPath) - const deadlineMs = Date.now() + timeoutMs - const remainingMs = (): number => - sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs - const pendingListenerCleanups: (() => void)[] = [] - const cleanupPendingListeners = (): void => { - for (const cleanup of pendingListenerCleanups.splice(0)) { - cleanup() - } - } - - try { - const pendingControlSocket = await connectDaemonSocket(this.socketPath, remainingMs()) - this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) - this.controlSocket = pendingControlSocket - const controlHello = await this.sendHello(this.controlSocket, token, 'control', remainingMs()) - const controlIdentity = controlHello.identity - this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) - pendingListenerCleanups.push( - attachControlResponseReader( - this.controlSocket, - (response) => this.pendingRequests.settle(response), - controlHello.remainder - ) - ) - - const pendingStreamSocket = await connectDaemonSocket(this.socketPath, remainingMs()) - this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) - this.streamSocket = pendingStreamSocket - const streamHello = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) - this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) - if (!sameDaemonIdentity(controlIdentity, streamHello.identity)) { - throw new DaemonProtocolError('Daemon identity changed during connection') - } - const streamReader = attachStreamEventReader(this.streamSocket, streamHello, (event) => { - this.eventListeners.each((listener) => listener(event)) - }) - pendingListenerCleanups.push(streamReader.detach) - - this.assertConnectionAttemptCurrent(attemptGeneration) - this.connected = true - this.observedAuthenticatedDisconnect = false - this.daemonIdentity = controlIdentity - this.daemonResizeModel = controlHello.resizeModel - this.disconnectArmed = true - this.connectionGeneration++ - - const gen = this.connectionGeneration - pendingListenerCleanups.push( - armDaemonSocketCloseHandlers( - this.controlSocket, - this.streamSocket, - () => this.handleDisconnect(gen), - streamReader.drainBuffered - ) - ) - this.cleanupSocketListeners = cleanupPendingListeners - } catch (error) { - cleanupPendingListeners() - this.controlSocket?.destroy() - this.streamSocket?.destroy() - this.controlSocket = null - this.streamSocket = null - this.connected = false - this.daemonIdentity = null - this.disconnectArmed = false - throw error - } - } async request( type: string, payload: unknown, timeoutMs = REQUEST_TIMEOUT_MS, - signal?: AbortSignal + signal?: AbortSignal, + onSent?: (requestId: string) => void ): Promise { if (!this.connected || !this.controlSocket) { // No socket is a transport failure, distinct from a daemon refusing the request. @@ -212,6 +33,7 @@ export class DaemonClient { payload, timeoutMs, ...(signal ? { signal } : {}), + ...(onSent ? { onSent } : {}), unmatchedCancelGraceMs: NOTIFY_SETTLEMENT_TIMEOUT_MS, onCreateCancellationFailure: () => this.handleDisconnect(generation), settleCreateCancellation: (sessionId, requestId) => @@ -271,73 +93,4 @@ export class DaemonClient { onDisconnected(listener: () => void): () => void { return this.disconnectedListeners.add(listener) } - - disconnect(): void { - this.connectionAttemptGeneration++ - this.connected = false - this.daemonIdentity = null - this.disconnectArmed = false - this.cleanupActiveSocketListeners() - - this.pendingRequests.rejectAll('Disconnected') - - this.controlSocket?.destroy() - this.streamSocket?.destroy() - this.controlSocket = null - this.streamSocket = null - } - - private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void { - if (attemptGeneration === this.connectionAttemptGeneration) { - return - } - socket?.destroy() - throw new DaemonProtocolError('Disconnected') - } - - private sendHello( - socket: Socket, - token: string, - role: 'control' | 'stream', - timeoutMs: number - ): ReturnType { - return sendDaemonHello({ - socket, - token, - role, - timeoutMs, - protocolVersion: this.protocolVersion, - clientId: this.clientId - }) - } - - private handleDisconnect(generation: number): void { - if (!this.disconnectArmed || generation !== this.connectionGeneration) { - return - } - this.disconnectArmed = false - this.connectionAttemptGeneration++ - if (this.daemonIdentity) { - this.observedAuthenticatedDisconnect = true - } - this.connected = false - this.daemonIdentity = null - this.daemonResizeModel = null - this.cleanupActiveSocketListeners() - - this.pendingRequests.rejectAll('Connection lost') - - this.controlSocket?.destroy() - this.streamSocket?.destroy() - this.controlSocket = null - this.streamSocket = null - - this.disconnectedListeners.each((listener) => listener()) - } - - private cleanupActiveSocketListeners(): void { - const cleanup = this.cleanupSocketListeners - this.cleanupSocketListeners = null - cleanup?.() - } } diff --git a/src/main/daemon/daemon-client-connection.ts b/src/main/daemon/daemon-client-connection.ts new file mode 100644 index 000000000000..387600a8f317 --- /dev/null +++ b/src/main/daemon/daemon-client-connection.ts @@ -0,0 +1,282 @@ +import type { Socket } from 'node:net' +import { readDaemonClientToken } from './daemon-client-token' +import { randomUUID } from 'node:crypto' +import { PROTOCOL_VERSION, DaemonProtocolError, type DaemonEndpointIdentity } from './types' +import { + armDaemonSocketCloseHandlers, + connectDaemonSocket, + waitForDaemonConnectionAttempt +} from './daemon-client-socket-connect' +import { DaemonClientListeners } from './daemon-client-listener-registry' +import { sameDaemonIdentity, sendDaemonHello } from './daemon-client-hello-handshake' +import { DaemonPendingRequests } from './daemon-client-pending-requests' +import { + attachControlResponseReader, + attachStreamEventReader +} from './daemon-client-ndjson-readers' +const CONNECT_TIMEOUT_MS = 5000 +const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 +export type DaemonClientOptions = { + socketPath: string + tokenPath: string + protocolVersion?: number +} + +export class DaemonClientConnection { + private socketPath: string + + private tokenPath: string + + private protocolVersion: number + + private clientId = randomUUID() + + protected controlSocket: Socket | null = null + + private streamSocket: Socket | null = null + + private connectionBorrowers = 0 + + private ownerReleased = false + + protected connected = false + + private disconnectArmed = false + + // Old-socket close events cannot tear down a replacement connection. + protected connectionGeneration = 0 + + // Concurrent pane spawns share their connection attempt. + private connectingPromise: Promise | null = null + + private connectionAttemptGeneration = 0 + + private daemonIdentity: DaemonEndpointIdentity | null = null + + private daemonResizeModel: string | null | undefined + + private observedAuthenticatedDisconnect = false + + protected pendingRequests = new DaemonPendingRequests() + + protected eventListeners = new DaemonClientListeners<(event: unknown) => void>() + + protected disconnectedListeners = new DaemonClientListeners<() => void>() + + private cleanupSocketListeners: (() => void) | null = null + + constructor(opts: DaemonClientOptions) { + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + } + + isConnected(): boolean { + return this.connected + } + + getDaemonIdentity(): DaemonEndpointIdentity | null { + return this.daemonIdentity ? { ...this.daemonIdentity } : null + } + + getDaemonResizeModel(): string | null | undefined { + return this.daemonResizeModel + } + + hasObservedAuthenticatedDisconnect(): boolean { + return this.observedAuthenticatedDisconnect + } + + async ensureConnected(): Promise { + return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false) + } + + async ensureConnectedWithin(timeoutMs: number): Promise { + return this.ensureConnectedWithTimeout(timeoutMs, true) + } + + private async ensureConnectedWithTimeout( + timeoutMs: number, + sharedBudget: boolean + ): Promise { + if (this.connected) { + return + } + if (this.connectingPromise) { + // Teardown shares one deadline; normal connects allow separate socket and handshake budgets. + const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS + return waitForDaemonConnectionAttempt(this.connectingPromise, waiterTimeoutMs) + } + + const attemptGeneration = this.connectionAttemptGeneration + this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget) + try { + await this.connectingPromise + } finally { + this.connectingPromise = null + } + } + + private async doConnect( + timeoutMs: number, + attemptGeneration: number, + sharedBudget: boolean + ): Promise { + const token = readDaemonClientToken(this.tokenPath) + const deadlineMs = Date.now() + timeoutMs + const remainingMs = (): number => + sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs + const pendingListenerCleanups: (() => void)[] = [] + const cleanupPendingListeners = (): void => { + for (const cleanup of pendingListenerCleanups.splice(0)) { + cleanup() + } + } + + try { + const pendingControlSocket = await connectDaemonSocket(this.socketPath, remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) + this.controlSocket = pendingControlSocket + const controlHello = await this.sendHello(this.controlSocket, token, 'control', remainingMs()) + const controlIdentity = controlHello.identity + this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) + pendingListenerCleanups.push( + attachControlResponseReader( + this.controlSocket, + (response) => this.pendingRequests.settle(response), + controlHello.remainder + ) + ) + + const pendingStreamSocket = await connectDaemonSocket(this.socketPath, remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) + this.streamSocket = pendingStreamSocket + const streamHello = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) + if (!sameDaemonIdentity(controlIdentity, streamHello.identity)) { + throw new DaemonProtocolError('Daemon identity changed during connection') + } + const streamReader = attachStreamEventReader(this.streamSocket, streamHello, (event) => { + this.eventListeners.each((listener) => listener(event)) + }) + pendingListenerCleanups.push(streamReader.detach) + + this.assertConnectionAttemptCurrent(attemptGeneration) + this.connected = true + this.observedAuthenticatedDisconnect = false + this.daemonIdentity = controlIdentity + this.daemonResizeModel = controlHello.resizeModel + this.disconnectArmed = true + this.connectionGeneration++ + + const gen = this.connectionGeneration + pendingListenerCleanups.push( + armDaemonSocketCloseHandlers( + this.controlSocket, + this.streamSocket, + () => this.handleDisconnect(gen), + streamReader.drainBuffered + ) + ) + this.cleanupSocketListeners = cleanupPendingListeners + } catch (error) { + cleanupPendingListeners() + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + this.connected = false + this.daemonIdentity = null + this.disconnectArmed = false + throw error + } + } + + async withRetainedConnection(operation: () => Promise): Promise { + this.connectionBorrowers++ + try { + return await operation() + } finally { + this.connectionBorrowers-- + if (this.ownerReleased && this.connectionBorrowers === 0) { + this.disconnect() + } + } + } + + releaseOwner(): void { + this.ownerReleased = true + if (this.connectionBorrowers === 0) { + this.disconnect() + } + } + + disconnect(): void { + this.connectionAttemptGeneration++ + this.connected = false + this.daemonIdentity = null + this.disconnectArmed = false + this.cleanupActiveSocketListeners() + + this.pendingRequests.rejectAll('Disconnected') + + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + } + + private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void { + if (attemptGeneration === this.connectionAttemptGeneration) { + return + } + socket?.destroy() + throw new DaemonProtocolError('Disconnected') + } + + private sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): ReturnType { + return sendDaemonHello({ + socket, + token, + role, + timeoutMs, + protocolVersion: this.protocolVersion, + clientId: this.clientId + }) + } + + protected handleDisconnect(generation: number): void { + if (!this.disconnectArmed || generation !== this.connectionGeneration) { + return + } + this.disconnectArmed = false + this.connectionAttemptGeneration++ + if (this.daemonIdentity) { + this.observedAuthenticatedDisconnect = true + } + this.connected = false + this.daemonIdentity = null + this.daemonResizeModel = null + this.cleanupActiveSocketListeners() + + this.pendingRequests.rejectAll('Connection lost') + + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + + this.disconnectedListeners.each((listener) => listener()) + } + + private cleanupActiveSocketListeners(): void { + const cleanup = this.cleanupSocketListeners + this.cleanupSocketListeners = null + cleanup?.() + } +} diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts index 8d67473511b8..9059f6fcfd8b 100644 --- a/src/main/daemon/daemon-client-connections.ts +++ b/src/main/daemon/daemon-client-connections.ts @@ -77,6 +77,14 @@ export class DaemonClientConnections { socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) } + matchesEndpoint(identity: { pid: number; startedAtMs: number; launchNonce: string }): boolean { + return ( + identity.pid === process.pid && + identity.startedAtMs === this.options.identity.startedAtMs && + identity.launchNonce === this.options.identity.launchNonce + ) + } + get(clientId: string): ConnectedDaemonClient | undefined { return this.clients.get(clientId) } diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts index d559476b07d1..e38e9484dfdc 100644 --- a/src/main/daemon/daemon-client-rpc-request.ts +++ b/src/main/daemon/daemon-client-rpc-request.ts @@ -15,6 +15,7 @@ type DaemonRpcRequestOptions = { type: string payload: unknown timeoutMs: number + onSent?: (requestId: string) => void signal?: AbortSignal /** Bound late-response waits after unmatched cancellation, including attach-only requests with no reply coming. */ unmatchedCancelGraceMs: number @@ -39,6 +40,15 @@ function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | n export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { // A stalled event loop can deliver a daemon cancellation before its overdue timer runs. const { payload, type } = opts + const retainSwitchOutcome = + type === 'createOrAttach' && + payload !== null && + typeof payload === 'object' && + 'switchExecution' in payload && + payload.switchExecution !== null && + typeof payload.switchExecution === 'object' && + 'phase' in payload.switchExecution && + payload.switchExecution.phase === 'commit' const createTimeoutError = (): DaemonRequestTimeoutError => new DaemonRequestTimeoutError(`Request ${type} timed out after ${opts.timeoutMs}ms`) const createSessionId = @@ -109,6 +119,11 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { void opts .settleCreateCancellation(createSessionId, opts.id) .then((result) => { + // The acknowledged revocation is not proof that a dispatched child exited. + if (retainSwitchOutcome) { + awaitLateResponseThenReject(error) + return + } if (result.canceled) { rejectAndDrop(error) return @@ -169,6 +184,7 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { return } try { + opts.onSent?.(opts.id) opts.socket.write(encoded) sent = true } catch (err) { diff --git a/src/main/daemon/daemon-create-or-attach-cancellation.ts b/src/main/daemon/daemon-create-or-attach-cancellation.ts new file mode 100644 index 000000000000..8fce79e5c38d --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-cancellation.ts @@ -0,0 +1,81 @@ +import type { DaemonRequest } from './types' +import type { DaemonRequestRouterOptions } from './daemon-request-router' +export async function cancelDaemonCreateOrAttach( + options: DaemonRequestRouterOptions, + clientId: string, + request: Extract +): Promise { + const client = options.connections.get(clientId) + if (request.payload.release) { + const release = request.payload.release + if ( + request.payload.settlement || + !client?.authenticatedPairEstablished || + !client.streamSocket || + !release || + typeof release.anchor !== 'string' || + typeof release.invocationId !== 'string' || + (release.outcome !== 'exited' && + release.outcome !== 'adopted' && + release.outcome !== 'failed') || + typeof request.payload.requestId !== 'string' || + typeof request.payload.sessionId !== 'string' || + (release.incarnationId !== undefined && typeof release.incarnationId !== 'string') || + !release.endpoint || + typeof release.endpoint.pid !== 'number' || + typeof release.endpoint.startedAtMs !== 'number' || + typeof release.endpoint.launchNonce !== 'string' || + !options.connections.matchesEndpoint(release.endpoint) + ) { + return { canceled: false, released: false } + } + const released = options.preparations.release( + request.payload.sessionId, + clientId, + request.payload.requestId, + release + ) + options.lifecycle.reevaluateIdleShutdown() + return { canceled: false, released } + } + if (request.payload.settlement) { + if ( + !client?.authenticatedPairEstablished || + !client.streamSocket || + !options.connections.matchesEndpoint(request.payload.settlement.endpoint) + ) { + return { canceled: false, settlement: { outcome: 'unknown' } } + } + return { + canceled: true, + retention: 'creation-settlement-v1', + settlement: await options.preparations.settle( + request.payload.sessionId, + clientId, + request.payload.requestId, + request.payload.settlement, + (result, code) => { + const sessionId = result.agentSessionEnsure?.owner.ptyId ?? request.payload.sessionId + options.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { + code, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + } + }) + options.streamDataBatcher.flush(clientId) + } + ) + } + } + return { + canceled: options.preparations.cancel(request.payload.sessionId, { + clientId, + ...(typeof request.payload.requestId === 'string' + ? { requestId: request.payload.requestId } + : {}) + }) + } +} diff --git a/src/main/daemon/daemon-create-or-attach-contract.ts b/src/main/daemon/daemon-create-or-attach-contract.ts new file mode 100644 index 000000000000..3429ac0800e3 --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-contract.ts @@ -0,0 +1,83 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' +import type { DaemonCreateOrAttachResult as CreateOrAttachResult } from './daemon-create-or-attach-result' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +export type CreateOrAttachRequest = { + id: string + type: 'createOrAttach' + payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + command?: string + startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent + /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ + attachOnly?: boolean + /** Preserve the selected shell instead of defaulting to the daemon’s platform fallback. */ + shellOverride?: string + terminalShellArgs?: string[] + /** Preferred WSL distro for generic `wsl.exe` launches. */ + terminalWindowsWslDistro?: string | null + /** Preserve the selected PowerShell implementation so daemon and local spawns resolve the same executable. */ + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + shellReadySupported?: boolean + shellReadyTimeoutMs?: number + /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ + switchExecution?: { + phase: 'prepare' | 'commit' + invocationId: string + anchor?: string + contract?: 'creation-settlement-v1' + remainingMs?: number + } + cancelAfterMs?: number + startupIngress?: PtyStartupIngressIntent + /** Optional; an old daemon ignores it and an old client omits it (legacy pacing). */ + spawnIntent?: SpawnIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + } +} + +export type CreateOrAttachSettlement = + | { outcome: 'exited' } + | { outcome: 'failed' } + | { outcome: 'owned'; result: CreateOrAttachResult } + | { outcome: 'unknown' } + +export type CancelCreateOrAttachResult = { + canceled: boolean + settlement?: CreateOrAttachSettlement + retention?: 'creation-settlement-v1' + released?: boolean +} + +export type CancelCreateOrAttachRequest = { + id: string + type: 'cancelCreateOrAttach' + payload: { + sessionId: string + requestId?: string + settlement?: { invocationId: string; anchor: string; endpoint: DaemonEndpointIdentity } + release?: { + invocationId: string + anchor: string + endpoint: DaemonEndpointIdentity + outcome: 'exited' | 'adopted' | 'failed' + incarnationId?: string + } + } +} diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index 44e10000f339..766346b7bb7e 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -4,6 +4,9 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonCreateOrAttachResult = { + /** A dispatched child remains owned after its initiating handoff lost authority. */ + switchOutcomeUnknown?: true + switchCleanupWitness?: string isNew: boolean snapshot: TerminalSnapshot | null pid: number | null diff --git a/src/main/daemon/daemon-fenced-termination.ts b/src/main/daemon/daemon-fenced-termination.ts index 47d4c4073b7c..28049553281d 100644 --- a/src/main/daemon/daemon-fenced-termination.ts +++ b/src/main/daemon/daemon-fenced-termination.ts @@ -36,6 +36,7 @@ export async function handleDaemonFencedTermination( expectedIncarnationId?: string requireNoAgentOwner?: boolean requireUnattached?: boolean + switchCondition?: PtyFencedTerminationRequest['switchCondition'] }, deps: { host: TerminalHost @@ -50,12 +51,15 @@ export async function handleDaemonFencedTermination( id: payload.sessionId, expectedIncarnationId: payload.expectedIncarnationId, requireNoAgentOwner: payload.requireNoAgentOwner === true, - requireUnattached: payload.requireUnattached === true + requireUnattached: payload.requireUnattached === true, + ...(payload.switchCondition ? { switchCondition: payload.switchCondition } : {}) }) if (!termination.accepted) { return termination.result } - deps.preparations.cancel(payload.sessionId) - deps.attachments.clearInput(payload.sessionId) + if (!payload.switchCondition) { + deps.preparations.cancel(payload.sessionId) + deps.attachments.clearInput(payload.sessionId) + } return termination.completion } diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index 29e53c32ff2f..d2b6c277c2d7 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -17,6 +17,7 @@ export type ConfirmShellForegroundRequest = Omit & { type: 'inspectProcess' payload: GetForegroundProcessRequest['payload'] & { + captureSwitchWitness?: string expectedIncarnationId?: string /** Older daemons ignore this and return a full capture. */ steadyState?: boolean diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index c7891cd20ef4..027ef6f10e02 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -124,6 +124,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { readonly listProcesses: Mock readonly listSessions: Mock readonly establishLifecycleLease: Mock + readonly settleSwitchWorkForReplacement: MockAdapter['settleSwitchWorkForReplacement'] + readonly inheritSwitchReleaseCleanup: MockAdapter['inheritSwitchReleaseCleanup'] readonly shutdown: Mock readonly dispose: Mock readonly disconnectOnly: Mock @@ -149,6 +151,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { throw lifecycleLeaseError } }) + this.settleSwitchWorkForReplacement = vi.fn(async () => {}) + this.inheritSwitchReleaseCleanup = vi.fn() this.shutdown = vi.fn(async () => {}) this.dispose = vi.fn() const disconnectOnlyError = disconnectOnlyErrors.shift() diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 187136a66425..06d730c2f916 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -1,4 +1,5 @@ import type { Mock } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' export type MockSpawner = { ensureRunning: Mock @@ -27,6 +28,8 @@ export type MockAdapter = { listProcesses: Mock listSessions: Mock establishLifecycleLease: Mock + settleSwitchWorkForReplacement: Mock + inheritSwitchReleaseCleanup: Mock shutdown: Mock dispose: Mock disconnectOnly: Mock diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 56e34a5c0d8d..e24f4bcfd1ce 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -97,6 +97,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { replacedByRestart: true }) expect(order).toEqual(['fanout', 'unbind']) + expect(originalAdapter.settleSwitchWorkForReplacement).toHaveBeenCalledOnce() + expect(originalAdapter.settleSwitchWorkForReplacement.mock.invocationCallOrder[0]).toBeLessThan( + originalAdapter.fanoutSyntheticExits.mock.invocationCallOrder[0] + ) + expect(adapterInstances[1].inheritSwitchReleaseCleanup).toHaveBeenCalledWith(originalAdapter) + expect( + adapterInstances[1].inheritSwitchReleaseCleanup.mock.invocationCallOrder[0] + ).toBeLessThan(originalAdapter.dispose.mock.invocationCallOrder[0]) expect(recoverRetired).toHaveBeenCalledOnce() expect(recoverRetired.mock.invocationCallOrder[0]).toBeLessThan( originalAdapter.fanoutSyntheticExits.mock.invocationCallOrder[0] diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 091383b5d8a8..916d943042f1 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,4 +1,5 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. +// v55 distinguishes physical session-switch authority from earlier daemons. // v54 reads the macOS process table in process instead of forking setuid ps on every poll (#31783). // v53 rolls the daemon-side terminal fixes since v1.4.241 (foreground confirmation for chat continuation, ConPTY reflow, WSL malloc tunables) into a fresh daemon. // v52 rolls the daemon-side terminal fixes since v1.4.240 (ConPTY, teardown, spawn pane keys) into a fresh daemon. @@ -12,7 +13,8 @@ // v43 dropped it with the revert); v41 stages long startup commands as sourced scripts; v40 rolled // the #25130/#24636 shell-wrapper changes and the wider agent list into a fresh daemon; older owners // stay attachable. -export const PROTOCOL_VERSION = 54 +export const PROTOCOL_VERSION = 55 +export const SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION = 55 export const FENCED_TERMINATION_PROTOCOL_VERSION = 51 export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 export const CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION = 43 @@ -48,7 +50,7 @@ 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, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, - 52, 53 + 52, 53, 54 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-provider-restart.ts b/src/main/daemon/daemon-provider-restart.ts index f47ff331aabd..0a960e62203b 100644 --- a/src/main/daemon/daemon-provider-restart.ts +++ b/src/main/daemon/daemon-provider-restart.ts @@ -90,6 +90,8 @@ async function runRestartDaemon( (legacy) => !legacy.hasRetiredForIdle() ) + await currentOnly.settleSwitchWorkForReplacement() + // Fan out exits before teardown: daemon shutdown does not deliver session exits to the renderer. const fallbackKilledCount = currentAdapter instanceof DegradedDaemonPtyProvider @@ -183,8 +185,11 @@ async function runRestartDaemon( throw error } + newCurrent.inheritSwitchReleaseCleanup(currentOnly) + // Dispose only old subscriptions after adoption; full disposal would kill shared legacy adapters. disposeProviderSubscriptionsOnly(currentAdapter) + currentOnly.dispose() replaceDaemonProvider(newProvider) diff --git a/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts index 66a3320ac812..4c607dcd8566 100644 --- a/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts +++ b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts @@ -1,12 +1,14 @@ import './mock-descendant-sweep' import { expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { getDaemonSocketPath } from './daemon-spawner' +import type { DaemonClientConnections } from './daemon-client-connections' +import { agentSessionSwitchDeadline } from '../../shared/agent-session-switch-execution' it('recovers concurrent spawns rejected by an adapter-initiated disconnect', async () => { const concurrentSpawnCount = 70 @@ -77,3 +79,93 @@ it('recovers concurrent spawns rejected by an adapter-initiated disconnect', asy rmSync(dir, { recursive: true, force: true }) } }) + +it.skipIf(process.platform === 'win32')( + 'keeps an exact dispatched owner unknown across an authenticated empty endpoint replacement', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'daemon-switch-replacement-')) + const socketPath = getDaemonSocketPath(directory) + const tokenPath = join(directory, 'test.token') + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const child = createMockSubprocess() + const originalSpawn = vi.fn(async () => { + entered.resolve() + await release.promise + return child + }) + const original = new DaemonServer({ + socketPath, + tokenPath, + launchNonce: 'original', + preparePtySpawn: async () => {}, + spawnSubprocess: originalSpawn + }) + await original.start() + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + const deadline = agentSessionSwitchDeadline() + let settled = false + let recover: (() => Promise) | undefined + const operation = adapter + .spawn({ + sessionId: 'exact-owner', + cols: 80, + rows: 24, + signal: deadline.signal, + switchExecution: { + invocationId: 'original-click', + ...deadline, + trackPhysical: (work, recovery) => { + recover = recovery + void work.then(() => { + settled = true + }) + return work + } + } + }) + .catch((error: unknown) => error) + let replacement: DaemonServer | undefined + try { + await entered.promise + // Replace only this fixture's POSIX socket; the original receiver and pending child remain alive. + unlinkSync(socketPath) + const replacementSpawn = vi.fn(() => createMockSubprocess()) + replacement = new DaemonServer({ + socketPath, + tokenPath, + launchNonce: 'replacement', + spawnSubprocess: replacementSpawn + }) + await replacement.start() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the exact owned fixture server's authenticated connection registry. + const connections = (original as unknown as { connections: DaemonClientConnections }) + .connections + connections.values().next().value?.controlSocket.destroy() + expect(await operation).toBeInstanceOf(Error) + await vi.waitFor(() => + expect(adapter.getLastAuthenticatedDaemonIdentity()?.launchNonce).toBe('replacement') + ) + expect(await adapter.listProcesses()).toEqual([]) + if (!recover) { + throw new Error('dispatched settlement recovery missing') + } + await expect(recover()).rejects.toThrow('Terminal owner unavailable') + expect(settled).toBe(false) + expect(replacementSpawn).not.toHaveBeenCalled() + expect(originalSpawn).toHaveBeenCalledOnce() + release.resolve() + await vi.waitFor(() => expect(child.forceKill).toHaveBeenCalled()) + // An empty new daemon still cannot answer for the old receiver's exact child. + expect(settled).toBe(false) + } finally { + release.resolve() + deadline.dispose() + adapter.dispose() + await operation + await original.shutdown() + await replacement?.shutdown() + rmSync(directory, { recursive: true, force: true }) + } + } +) diff --git a/src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts b/src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts index 670fb77b414c..87563f108016 100644 --- a/src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts +++ b/src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts @@ -183,9 +183,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const checkpoint = vi.fn(async () => 'committed' as const) const appendIncrements = vi.fn(async () => 'ok' as const) const dispose = vi.fn(async () => {}) - const disconnect = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replace only the owned history fixture dependencies with their typed stubs. const internals = historyAdapter as unknown as { - client: { request: typeof request; disconnect: typeof disconnect } + client: { request: typeof request; disconnect: () => void } historyManager: { checkpoint: typeof checkpoint appendIncrements: typeof appendIncrements @@ -193,7 +193,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } checkpointSessions(sessionIds: Iterable): Promise> } - internals.client = { request, disconnect } + internals.client.request = request internals.historyManager = { checkpoint, appendIncrements, dispose } const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f']) @@ -248,7 +248,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { nonFinalCheckpointAdmissionSessionIds: Set tryAdmitNonFinalCheckpoint(sessionId: string): boolean } - internals.client = { request, disconnect: vi.fn() } + internals.client.request = request internals.historyManager = { checkpoint: vi.fn(async () => 'committed' as const), appendIncrements, @@ -334,7 +334,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { client: { request: typeof request; disconnect: ReturnType } checkpointSessions(sessionIds: Iterable): Promise> } - internals.client = { request, disconnect: vi.fn() } + internals.client.request = request const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -367,7 +367,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { client: { request: typeof request; disconnect: ReturnType } checkpointSessions(sessionIds: Iterable): Promise> } - internals.client = { request, disconnect: vi.fn() } + internals.client.request = request const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -431,7 +431,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) const internals = historyAdapter as unknown as CooldownInternals - internals.client = { request, disconnect: vi.fn() } + internals.client.request = request internals.historyManager = { checkpoint: vi.fn(async () => takeResult.checkpointResult ?? 'committed'), appendIncrements: vi.fn(async () => takeResult.appendResult ?? 'ok'), diff --git a/src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts b/src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts index 895b60222116..c4de28ed9266 100644 --- a/src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts +++ b/src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts @@ -200,7 +200,7 @@ describe('DaemonPtyAdapter checkpoints rebased on live', () => { expect(await manager.checkpoint(sessionId, disk.getSnapshot())).toBe('committed') expect(live.writeSync(base + drained)).toBe(true) const liveSnapshot = { ...live.getSnapshot(), outputSequence: 99 } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a teardown checkpoint only calls client.request and the history manager/reader, all real or faked here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stub only external RPC answers, keeping actual client and history lifetime. const internals = historyAdapter as unknown as { client: { request: ReturnType; disconnect: () => void } checkpointSessions( @@ -208,16 +208,13 @@ describe('DaemonPtyAdapter checkpoints rebased on live', () => { opts?: { final?: boolean; teardown?: boolean } ): Promise> } - internals.client = { - request: vi.fn(async () => ({ - records: [{ kind: 'output', data: marker }], - drainedRecords: [{ kind: 'output', data: drained }], - seq: 2, - overflowed: false, - snapshot: liveSnapshot - })), - disconnect: () => {} - } + internals.client.request = vi.fn(async () => ({ + records: [{ kind: 'output', data: marker }], + drainedRecords: [{ kind: 'output', data: drained }], + seq: 2, + overflowed: false, + snapshot: liveSnapshot + })) const append = vi.spyOn(manager, 'appendIncrements') diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index bd9033d592e6..1210bc78e6f4 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -1,3 +1,4 @@ +import { registerDaemonSwitchCompatibilityTests } from './daemon-pty-adapter-switch-compatibility.test-fixture' import './mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rmSync } from 'node:fs' @@ -14,7 +15,6 @@ import type { DaemonServer } from './daemon-server' import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' import type * as DaemonHealthModule from './daemon-health' import type * as DaemonTccAttributionModule from './daemon-tcc-attribution' - const { getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock } = vi.hoisted( () => ({ getMacDaemonSystemResolverHealthMock: vi.fn( @@ -472,14 +472,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { function createProbeAdapter( protocolVersion: number, - request: ReturnType + request: (...args: Parameters) => Promise ): DaemonPtyAdapter { const probeAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) - ;( - probeAdapter as unknown as { - client: { request: ReturnType; disconnect: ReturnType } - } - ).client = { request, disconnect: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stub external RPC answers while keeping the actual client's transport lifetime. + const client = (probeAdapter as unknown as { client: DaemonClient }).client + vi.spyOn(client, 'request').mockImplementation(request) return probeAdapter } @@ -681,23 +679,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('inspectProcess on pre-inspection daemon protocols', () => { - type ClientInternals = { - client: { request: ReturnType; disconnect: ReturnType } - } - function createInspectionAdapter( protocolVersion: number, - request: ReturnType + request: (...args: Parameters) => Promise ): DaemonPtyAdapter { const inspectionAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) - ;(inspectionAdapter as unknown as ClientInternals).client = { - request, - disconnect: vi.fn() - } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stub external RPC answers while keeping the actual client's transport lifetime. + const client = (inspectionAdapter as unknown as { client: DaemonClient }).client + vi.spyOn(client, 'request').mockImplementation(request) return inspectionAdapter } @@ -804,4 +797,30 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { current.dispose() }) }) + registerDaemonSwitchCompatibilityTests({ + get server() { + return server + }, + set server(value) { + server = value + }, + get adapter() { + return adapter + }, + set adapter(value) { + adapter = value + }, + get socketPath() { + return socketPath + }, + set socketPath(value) { + socketPath = value + }, + get tokenPath() { + return tokenPath + }, + set tokenPath(value) { + tokenPath = value + } + }) }) diff --git a/src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts b/src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts index 3c3847ea0ad9..849a92cce05a 100644 --- a/src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts +++ b/src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts @@ -15,7 +15,8 @@ function createAdapter( tokenPath: '/tmp/orca-steady-state-compat.token', protocolVersion }) - ;(adapter as unknown as ClientInternals).client = { request, disconnect: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replace only external RPC answers; preserve the actual client lifetime. + ;(adapter as unknown as ClientInternals).client.request = request return adapter } diff --git a/src/main/daemon/daemon-pty-adapter-switch-compatibility.test-fixture.ts b/src/main/daemon/daemon-pty-adapter-switch-compatibility.test-fixture.ts new file mode 100644 index 000000000000..a955970ddad3 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-switch-compatibility.test-fixture.ts @@ -0,0 +1,81 @@ +import './mock-descendant-sweep' +import { expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { agentSessionSwitchDeadline } from '../../shared/agent-session-switch-execution' +import { createMockSubprocess } from './daemon-pty-adapter-test-harness' +export function registerDaemonSwitchCompatibilityTests(fixture: { + server: DaemonServer + adapter: DaemonPtyAdapter + socketPath: string + tokenPath: string +}) { + it.each([53, 54, 55])( + 'uses v%i creation with its supported switch authority', + async (protocolVersion) => { + fixture.adapter.dispose() + await fixture.server.shutdown() + const spawn = vi.fn(() => createMockSubprocess()) + fixture.server = new DaemonServer({ + socketPath: fixture.socketPath, + tokenPath: fixture.tokenPath, + protocolVersion, + spawnSubprocess: spawn + }) + await fixture.server.start() + fixture.adapter = new DaemonPtyAdapter({ + socketPath: fixture.socketPath, + tokenPath: fixture.tokenPath, + protocolVersion + }) + const request = vi.spyOn(DaemonClient.prototype, 'request') + const deadline = agentSessionSwitchDeadline() + try { + expect( + await fixture.adapter.spawn({ sessionId: 'legacy-ordinary', cols: 80, rows: 24 }) + ).toMatchObject({ id: 'legacy-ordinary' }) + const switched = await fixture.adapter.spawn({ + sessionId: 'switch-destination', + cols: 80, + rows: 24, + switchExecution: { invocationId: 'new-click', ...deadline } + }) + expect(switched).toMatchObject({ id: 'switch-destination' }) + expect(spawn).toHaveBeenCalledTimes(2) + const creates = request.mock.calls.filter(([type]) => type === 'createOrAttach') + if (protocolVersion < 55) { + expect(creates).toHaveLength(2) + for (const create of creates) { + expect(create[1]).not.toHaveProperty('switchExecution') + } + } else { + expect(creates).toHaveLength(3) + expect(creates[1]?.[1]).toMatchObject({ switchExecution: { phase: 'prepare' } }) + expect(creates[2]?.[1]).toMatchObject({ switchExecution: { phase: 'commit' } }) + } + const releases = () => + request.mock.calls.filter(([type]) => type === 'cancelCreateOrAttach') + if (protocolVersion < 55) { + expect(releases()).toEqual([]) + } else { + await vi.waitFor(() => expect(releases()).toHaveLength(1)) + expect(releases()[0]?.[1]).toEqual({ + sessionId: 'switch-destination', + requestId: expect.any(String), + release: { + invocationId: 'new-click', + anchor: expect.any(String), + endpoint: fixture.adapter.getDaemonIdentity(), + outcome: 'adopted', + incarnationId: switched.incarnationId + } + }) + } + } finally { + deadline.dispose() + request.mockRestore() + } + } + ) +} diff --git a/src/main/daemon/daemon-pty-adapter-test-harness.ts b/src/main/daemon/daemon-pty-adapter-test-harness.ts index 4e8b520024a3..7e52d636c3ef 100644 --- a/src/main/daemon/daemon-pty-adapter-test-harness.ts +++ b/src/main/daemon/daemon-pty-adapter-test-harness.ts @@ -7,9 +7,10 @@ import type { DaemonPtyAdapterOptions } from './daemon-pty-runtime-state' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' import type { SubprocessHandle } from './session-subprocess-handle' +import type { TerminalHostOptions } from './terminal-host-options' import type { DaemonFileLog } from './daemon-file-log' -export type SpawnSubprocess = ConstructorParameters[0]['spawnSubprocess'] +export type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] export type DaemonAdapterHarness = { dir: string @@ -26,6 +27,7 @@ function createTestDir(): string { } export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { + forceKill: ReturnType> write: ReturnType void>> pause: ReturnType void>> resume: ReturnType void>> diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index b5566659998b..bf8af2bad0b4 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -3,7 +3,10 @@ import { type PtyFencedTerminationRequest, type PtyFencedTerminationOutcome } from '../../shared/pty-fenced-termination' -import { FENCED_TERMINATION_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + FENCED_TERMINATION_PROTOCOL_VERSION, + SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION +} from './daemon-protocol-version' import { emitPtyListeners, createPtyExitPayload } from './daemon-pty-listener-emission' import type { DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { logWslPaneDiagnostic } from './daemon-wsl-diagnostic-log' @@ -12,7 +15,6 @@ import { isProvenProcessExit } from '../../shared/terminal-exit-cause' import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' import type { DaemonPtyAdapterOptions } from './daemon-pty-runtime-state' import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' -import type { DaemonEndpointIdentity } from './daemon-hello-protocol' import type { IPtyProvider } from '../providers/pty-provider-contract' import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' import { @@ -31,7 +33,9 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro constructor(opts: DaemonPtyAdapterOptions) { super(opts) // Registered after the base listener, which marks the sessions awaiting recovery first. - this.client.onDisconnected(() => this.recoverAfterEndpointLoss()) + this.disconnectedListenerCleanups.push( + this.client.onDisconnected(() => this.recoverAfterEndpointLoss()) + ) } supportsFencedTermination(_id: string): boolean { @@ -39,7 +43,10 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro } async killFenced(request: PtyFencedTerminationRequest): Promise { - if (!this.supportsFencedTermination(request.id)) { + if ( + !this.supportsFencedTermination(request.id) || + (request.switchCondition && this.protocolVersion < SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION) + ) { return { outcome: 'refused', reason: 'unsupported' } } // Capture the checked identity before waiting on connection or lifecycle locks. @@ -53,7 +60,8 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro immediate: true, expectedIncarnationId, requireNoAgentOwner, - requireUnattached + requireUnattached, + ...(request.switchCondition ? { switchCondition: request.switchCondition } : {}) }) if (!isPtyFencedTerminationOutcome(result)) { return { outcome: 'refused', reason: 'unverifiable' } @@ -126,11 +134,6 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro return 'unknown' } - /** Identity of the daemon behind this adapter; null until hello completes or after a disconnect. */ - getDaemonIdentity(): DaemonEndpointIdentity | null { - return this.client.getDaemonIdentity() - } - protected setupEventRouting(): void { if (this.removeEventListener) { return @@ -236,6 +239,7 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro return } this.finishExit(event.sessionId, event.payload) + void this.reconcileSwitchSpawns().catch(() => {}) } }) } diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts index 721fc6da8339..853668110af3 100644 --- a/src/main/daemon/daemon-pty-connection-lifecycle.ts +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -35,6 +35,7 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri this.releasePendingRespawnAdoptionLease() } this.recordAuthenticatedIdentity() + this.drainSwitchReleaseCleanup() // Sample before installing routing so a fresh connection resyncs daemon-local background state. const isFreshConnection = this.removeEventListener === null this.setupEventRouting() diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts index 54f1f0dd48e3..06e5e3681896 100644 --- a/src/main/daemon/daemon-pty-daemon-recovery.ts +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -9,11 +9,8 @@ import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import type { ListSessionsResult } from './types' import type { PtyBackgroundStreamEvent } from '../providers/pty-provider-contract' import type { TerminalLimitStatus, TerminalLimitUpdate } from '../../shared/terminal-limit-policy' -import type { DaemonEndpointIdentity } from './daemon-hello-protocol' export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence { - abstract getDaemonIdentity(): DaemonEndpointIdentity | null - async terminalLimit(update?: TerminalLimitUpdate): Promise { await this.ensureConnected() const identity = this.getDaemonIdentity() @@ -99,6 +96,9 @@ export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersist // Reconnect on endpoint loss without waiting for input; only daemon-gone errors permit respawn. protected recoverAfterEndpointLoss(): void { + if (this.switchSpawnSettlements.size > 0) { + void this.reconcileSwitchSpawns().catch(() => {}) + } if (this.activeSessionIds.size > 0) { this.reconnectAfterWriteFailure() } diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts index 27563bacddf8..31e54ba56e9d 100644 --- a/src/main/daemon/daemon-pty-event-subscriptions.ts +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -79,7 +79,10 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent .dispose() .catch((err) => console.warn('[history] dispose failed:', err)) } - this.client.disconnect() + for (const cleanup of this.disconnectedListenerCleanups.splice(0)) { + cleanup() + } + this.client.releaseOwner() } async establishLifecycleLease(): Promise { @@ -251,6 +254,9 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent // Closing auth sockets still permits the daemon to retire itself once empty. } } - this.client.disconnect() + for (const cleanup of this.disconnectedListenerCleanups.splice(0)) { + cleanup() + } + this.client.releaseOwner() } } diff --git a/src/main/daemon/daemon-pty-history-isolation.ts b/src/main/daemon/daemon-pty-history-isolation.ts new file mode 100644 index 000000000000..1403162c7582 --- /dev/null +++ b/src/main/daemon/daemon-pty-history-isolation.ts @@ -0,0 +1,44 @@ +import type { PtySpawnOptions } from '../providers/pty-provider-contract' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { resolveUnixShellPath } from '../providers/local-pty-utils' +import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' +import { addWslEnvKeys } from '../wsl-env' +import { resolveWslSessionContext } from './wsl-session-context' + +export function withDaemonPtyHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { + const wslContext = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId: opts.sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + }) + if ( + opts.attachOnly === true || + (opts.sessionId !== undefined && opts.isNewSession !== true) || + !opts.worktreeId || + opts.historyIsolationEnabled !== true || + (process.platform === 'win32' && !wslContext) + ) { + return opts + } + const env = { ...opts.env } + const preferredShell = wslContext + ? 'bash' + : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' + const shellPath = resolveUnixShellPath(preferredShell) + const historyArgs = [ + env, + opts.worktreeId, + shellPath, + opts.cwd ?? resolveSafePtyDefaultCwd() + ] as const + const result = wslContext + ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) + : injectHistoryEnv(...historyArgs) + if (wslContext) { + injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) + addWslEnvKeys(env, ['HISTFILE', 'fish_history']) + } + logHistoryInjection(opts.worktreeId, result) + return { ...opts, env } +} diff --git a/src/main/daemon/daemon-pty-operation-state.ts b/src/main/daemon/daemon-pty-operation-state.ts new file mode 100644 index 000000000000..c7a2f11380c0 --- /dev/null +++ b/src/main/daemon/daemon-pty-operation-state.ts @@ -0,0 +1,67 @@ +import type { WslBridgeBootIdentity } from './daemon-wsl-bridge-boot-identity' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { TakePendingOutputResult } from './types' +import type { HistoryCheckpointResult, HistoryRecoveryFreeze } from './history-manager' +import type { ReadRunningWslDistros } from './daemon-wsl-bridge-exit' +export type PendingDaemonSpawnOperation = { + switchSettlement?: { + adopt?: (incarnationId: string) => void + completion: Promise + recover: () => Promise + consume: (outcome: 'exited' | 'adopted' | 'failed', incarnationId?: string) => void + } + exitsBySessionId: Map + ignoredExitIncarnationIds: Set + ignoreNextExit: boolean +} + +export type HistoryRecoveryContext = { + freeze: HistoryRecoveryFreeze | null + unreadableSessionId: string | null + identityChanged: boolean +} + +export type SnapshotCheckpointResult = { + checkpoint: HistoryCheckpointResult + snapshot: NonNullable | null +} + +export type DaemonPtyShutdownOptions = { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string +} + +export type DaemonPtyAdapterOptions = { + socketPath: string + tokenPath: string + pidPath?: string + profileScope?: string + protocolVersion?: number + historyPath?: string + runtimeDir?: string + packagedAppVersion?: string | null + respawn?: (reason: DaemonRespawnReason) => Promise void)> + /** Test seam; production reads the shared, coalesced `wsl --list --running`. */ + readRunningWslDistros?: ReadRunningWslDistros + /** Test seam; production reads each distro's boot id through the background-work gate. */ + wslBridgeBootIdentity?: WslBridgeBootIdentity +} + +export type DaemonRespawnReason = + | 'daemon_died' + | 'unhealthy_resolver' + | 'stale_bundle' + | 'severed_tcc_attribution' + +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + +export type DaemonIdleRetirementResult = + | { state: 'retiring' } + | { state: 'busy'; liveSessions: number | null; admissionReopened?: true } + | { state: 'unsupported' } + | { state: 'unverifiable' } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index fc93492bf5db..2cee61155bf9 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -1,3 +1,4 @@ +import { SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION } from './daemon-protocol-version' import { isShellProcess } from '../../shared/agent-detection' import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots' import { parsePtySessionId } from '../../shared/pty-session-id-format' @@ -46,6 +47,10 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } return this.client.request('inspectProcess', { sessionId: id, + ...(options?.captureSwitchWitness && + this.protocolVersion >= SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION + ? { captureSwitchWitness: options.captureSwitchWitness } + : {}), ...(options?.expectedIncarnationId ? { expectedIncarnationId: options.expectedIncarnationId } : {}), diff --git a/src/main/daemon/daemon-pty-reattach-result.ts b/src/main/daemon/daemon-pty-reattach-result.ts new file mode 100644 index 000000000000..c9282ca3880a --- /dev/null +++ b/src/main/daemon/daemon-pty-reattach-result.ts @@ -0,0 +1,55 @@ +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { daemonSpawnResultIdentity } from './daemon-spawn-result-identity' +import type { PtySpawnResult } from '../providers/pty-provider-contract' +import type { CreateOrAttachResult, TerminalSnapshot } from './types' +export function daemonPtyReattachResult( + sessionId: string, + result: CreateOrAttachResult, + pid: number | null, + providerWslDistro: string | null | undefined, + reattachSnapshot: TerminalSnapshot, + providerSequence: PtySpawnResult['providerSequence'] +): PtySpawnResult { + const reattachProviderSequence = + typeof reattachSnapshot.outputSequence === 'number' + ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } + : providerSequence + const isAltScreen = reattachSnapshot.modes.alternateScreen + const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences + const snapshotFrame = reattachSnapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame + // Reassert kitty flags after payload screen switches; carry known zero separately from unknown. + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( + reattachSnapshot.modes.kittyKeyboardFlags + ) + return { + id: sessionId, + ...daemonSpawnResultIdentity(result), + pid, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + snapshot: snapshotPayload, + snapshotCols: reattachSnapshot.cols, + snapshotRows: reattachSnapshot.rows, + // Only alt frames need a separate restore; normal history can replay at its captured grid. + ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi + } + : {}), + ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), + ...(kittyKeyboardFlags !== undefined ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), + ...(reattachSnapshot.terminalOwner + ? { snapshotTerminalOwner: reattachSnapshot.terminalOwner } + : {}), + isReattach: true, + isAlternateScreen: isAltScreen, + // Snapshot ANSI lacks title frames; carry the title separately across relaunch. + ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), + // Replay the mid-escape tail after renderer reset so split escapes do not render literally. + ...(reattachSnapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } + : {}) + } +} diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index 1f2082f6f01d..bb3cca942310 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -1,3 +1,9 @@ +import type { + PendingDaemonSpawnOperation, + DaemonPtyAdapterOptions, + DaemonIdentityChangeEvent, + DaemonIdleRetirementResult +} from './daemon-pty-operation-state' import { DaemonClient } from './client' import { DaemonPtyInputClosedIncarnations } from './daemon-pty-input-closed-incarnations' import { DaemonPtyInventoryObservation } from './daemon-pty-inventory-observation' @@ -20,70 +26,25 @@ import { GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PROTOCOL_VERSION, supportsMode2031UnsubscribeFact, - supportsPtyStartupIngress, - type TakePendingOutputResult + supportsPtyStartupIngress } from './types' import { ColdRestorePayloadCache } from './cold-restore-payload-cache' -import { - HistoryManager, - type HistoryCheckpointResult, - type HistoryRecoveryFreeze -} from './history-manager' +import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import type { PtyBackgroundStreamEvent } from '../providers/pty-provider-contract' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import type { ReadRunningWslDistros } from './daemon-wsl-bridge-exit' import { WslBridgeBootIdentity } from './daemon-wsl-bridge-boot-identity' - -export type PendingDaemonSpawnOperation = { - exitsBySessionId: Map - ignoredExitIncarnationIds: Set - ignoreNextExit: boolean -} - -export type HistoryRecoveryContext = { - freeze: HistoryRecoveryFreeze | null - unreadableSessionId: string | null - identityChanged: boolean -} - -export type SnapshotCheckpointResult = { - checkpoint: HistoryCheckpointResult - snapshot: NonNullable | null -} - -export type DaemonPtyAdapterOptions = { - socketPath: string - tokenPath: string - pidPath?: string - profileScope?: string - protocolVersion?: number - historyPath?: string - runtimeDir?: string - packagedAppVersion?: string | null - respawn?: (reason: DaemonRespawnReason) => Promise void)> - /** Test seam; production reads the shared, coalesced `wsl --list --running`. */ - readRunningWslDistros?: ReadRunningWslDistros - /** Test seam; production reads each distro's boot id through the background-work gate. */ - wslBridgeBootIdentity?: WslBridgeBootIdentity -} - -export type DaemonRespawnReason = - | 'daemon_died' - | 'unhealthy_resolver' - | 'stale_bundle' - | 'severed_tcc_attribution' - -export type DaemonIdentityChangeEvent = { - previous: DaemonEndpointIdentity - current: DaemonEndpointIdentity -} - -export type DaemonIdleRetirementResult = - | { state: 'retiring' } - | { state: 'busy'; liveSessions: number | null; admissionReopened?: true } - | { state: 'unsupported' } - | { state: 'unverifiable' } +export { + type PendingDaemonSpawnOperation, + type HistoryRecoveryContext, + type SnapshotCheckpointResult, + type DaemonPtyShutdownOptions, + type DaemonPtyAdapterOptions, + type DaemonRespawnReason, + type DaemonIdentityChangeEvent, + type DaemonIdleRetirementResult +} from './daemon-pty-operation-state' export abstract class DaemonPtyRuntimeState { protected readonly readRunningWslDistros: ReadRunningWslDistros | undefined @@ -119,6 +80,7 @@ export abstract class DaemonPtyRuntimeState { protected exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = [] protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + protected readonly disconnectedListenerCleanups: (() => void)[] = [] protected removeEventListener: (() => void) | null = null protected initialCwds = new Map() protected wslDistrosBySessionId = new Map() @@ -259,20 +221,22 @@ export abstract class DaemonPtyRuntimeState { this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) - this.client.onDisconnected(() => { - this.ptyInventory.reset() - if (!this.respawnAdoptionClosed) { - this.writeRecoveryAttempted = false - for (const id of this.activeSessionIds) { - this.sessionsAwaitingDaemonRecovery.add(id) + this.disconnectedListenerCleanups.push( + this.client.onDisconnected(() => { + this.ptyInventory.reset() + if (!this.respawnAdoptionClosed) { + this.writeRecoveryAttempted = false + for (const id of this.activeSessionIds) { + this.sessionsAwaitingDaemonRecovery.add(id) + } } - } - for (const id of this.pausedProducerSessionIds) { - this.producerResumesOwedOnReconnect.add(id) - } - this.pausedProducerSessionIds.clear() - this.observeAuditFailure('transport_closed') - }) + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') + }) + ) } supportsGitCredentialGuardHost(): boolean { @@ -295,6 +259,11 @@ export abstract class DaemonPtyRuntimeState { return this.historyManager } + /** Identity of the daemon behind this adapter; null until hello completes or after a disconnect. */ + getDaemonIdentity(): DaemonEndpointIdentity | null { + return this.client.getDaemonIdentity() + } + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null } diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index c83ce19f04a0..1fed332c7657 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -1,3 +1,4 @@ +import type { DaemonPtyShutdownOptions } from './daemon-pty-runtime-state' import { coldRestorePayloadFromInfo, type ColdRestorePayload } from './cold-restore-payload-cache' import { isUnknownRequestTypeError } from './daemon-endpoint-errors' import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version' @@ -17,7 +18,6 @@ import type { PtySpawnResult } from '../providers/pty-provider-contract' export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 const MAX_TOMBSTONES = 1000 -// A busy daemon must not eat the final checkpoint's share of the Sleep deadline. export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { protected abstract adoptListedSessionForSleep( @@ -106,10 +106,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { this.listingDaemons = daemons } - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { + async shutdown(id: string, opts: DaemonPtyShutdownOptions): Promise { if (opts.keepHistory && this.disconnectOnlyPromise) { throw new Error('Cannot keep history after daemon disconnect has started') } @@ -133,7 +130,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { protected async shutdownWithHistoryLock( id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: DaemonPtyShutdownOptions ): Promise { // Connect lazily, sharing one deadline with checkpoint waits and kill; durable writes themselves remain unbounded. await this.ensureConnected(opts.deadlineMs) @@ -207,6 +204,20 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { this.historyManager?.suspendSession(id) } } + if (opts.expectedIncarnationId) { + // Older daemons cannot fence kill: compare their fresh inventory immediately before it. + const { sessions } = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(opts.deadlineMs) + ) + if ( + sessions.find((session) => session.sessionId === id)?.incarnationId !== + opts.expectedIncarnationId + ) { + throw new Error('terminal_handle_stale') + } + } try { await this.client.request( 'kill', @@ -294,21 +305,6 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { return coldRestorePayloadFromInfo(restoreInfo) } - async sendSignal(id: string, signal: string): Promise { - await this.client.request('signal', { sessionId: id, signal }) - } - - async getCwd(id: string): Promise { - try { - const result = await this.client.request<{ cwd: string | null }>('getCwd', { - sessionId: id - }) - return result.cwd ?? '' - } catch { - return '' - } - } - async getInitialCwd(id: string): Promise { return this.initialCwds.get(id) ?? '' } diff --git a/src/main/daemon/daemon-pty-session-input.ts b/src/main/daemon/daemon-pty-session-input.ts index 44123e92f27b..a2d5e1b7e4ce 100644 --- a/src/main/daemon/daemon-pty-session-input.ts +++ b/src/main/daemon/daemon-pty-session-input.ts @@ -75,6 +75,21 @@ export abstract class DaemonPtySessionInput extends DaemonPtySessionSpawn { this.reconnectAfterWriteFailure() } + async sendSignal(id: string, signal: string): Promise { + await this.client.request('signal', { sessionId: id, signal }) + } + + async getCwd(id: string): Promise { + try { + const result = await this.client.request<{ cwd: string | null }>('getCwd', { + sessionId: id + }) + return result.cwd ?? '' + } catch { + return '' + } + } + resize(id: string, cols: number, rows: number): void { this.markSessionDirty(id) this.client.notify('resize', { sessionId: id, cols, rows }) diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 296f46c018f4..18189acb17bb 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -1,3 +1,4 @@ +import { withDaemonPtyHistoryIsolation } from './daemon-pty-history-isolation' import type { HistoryRecoveryContext, PendingDaemonSpawnOperation @@ -19,8 +20,6 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { probeRecoveredStartupCwdDirectory } from '../ipc/pty/host-env/startup-cwd-directory-exists' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/pty-provider-contract' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' import { holdPassiveRestoreOfStoppedWslDistro } from './daemon-wsl-passive-restore-hold' export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { @@ -52,59 +51,42 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { identityChanged: false } try { + const create = async () => { + const retained = operation.switchSettlement + if (retained) { + await retained.recover() + throw new Error('Terminal creation was interrupted; try again') + } + return this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) + } return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) + // Switch authority already owns the source proof; an uncertain ordinary send cannot replay. + opts.switchExecution ? create() : this.withDaemonRetry(create) ) } finally { if (historyRecovery.freeze) { this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) } - this.pendingClaimSpawnOperations.delete(operation) - operations.delete(operation) - if (operations.size === 0) { - this.pendingSpawnOperationsBySessionId.delete(sessionId) + const forget = () => { + this.pendingClaimSpawnOperations.delete(operation) + operations.delete(operation) + if (operations.size === 0) { + this.pendingSpawnOperationsBySessionId.delete(sessionId) + } + } + const retained = this.switchSpawnSettlements.get(operation) + if (retained) { + void retained.completion.then(forget) + // This is the same receiver join; it cannot replay the failed creation. + void retained.recover().catch(() => {}) + } else { + forget() } } } protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } + return withDaemonPtyHistoryIsolation(opts) } protected async doSpawn( diff --git a/src/main/daemon/daemon-pty-spawn-context.ts b/src/main/daemon/daemon-pty-spawn-context.ts new file mode 100644 index 000000000000..e0fc08fa1f64 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-context.ts @@ -0,0 +1,25 @@ +import type { + HistoryRecoveryContext, + PendingDaemonSpawnOperation +} from './daemon-pty-runtime-state' +import type { ColdRestoreInfo } from './history-reader' +import type { PtySpawnOptions } from '../providers/pty-provider-contract' +export type DaemonPtySpawnContext = { + opts: PtySpawnOptions + operation: PendingDaemonSpawnOperation + historyRecovery: HistoryRecoveryContext + requestedSessionId: string + attachOnly: boolean + emulateLegacyAttachOnly: boolean + sessionId: string + wslDistro: string | undefined + restoreInfo: ColdRestoreInfo | null + restoreSkippedForLiveSession: boolean + effectiveCwd: string | undefined + effectiveCols: number + effectiveRows: number + shellReadySupported: boolean + shellReadyTimeoutMs: number | undefined + historySeedSegments: readonly string[] | null + detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise +} diff --git a/src/main/daemon/daemon-pty-spawn-preparation-release.ts b/src/main/daemon/daemon-pty-spawn-preparation-release.ts new file mode 100644 index 000000000000..7c92323cf896 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-preparation-release.ts @@ -0,0 +1,107 @@ +import type { + PendingPtySpawnPreparation, + CompletedPreparation +} from './daemon-pty-spawn-preparation-state' +export function releaseDaemonSpawnPreparation( + retained: Map, + sessionId: string, + clientId: string, + requestId: unknown, + release: { + anchor: string + invocationId: string + outcome: 'exited' | 'adopted' | 'failed' + incarnationId?: string + } +): boolean { + const key = JSON.stringify([sessionId, clientId, release.anchor]) + const entry = retained.get(key) + // GC acknowledgement is distinct from physical proof and is safe to repeat after deletion. + if (!entry) { + return true + } + if (typeof requestId !== 'string' || entry.requestId !== requestId) { + return false + } + if ('outcome' in entry) { + if ( + entry.invocationId !== release.invocationId || + (release.outcome === 'failed' ? entry.outcome !== 'failed' : entry.outcome === 'failed') || + (release.outcome === 'adopted' && + (!release.incarnationId || release.incarnationId !== entry.incarnationId)) + ) { + return false + } + } else { + const settlement = entry.settlement + if ( + entry.switchAnchor?.invocationId !== release.invocationId || + !settlement?.producerFinished + ) { + return false + } + if (release.outcome === 'failed') { + // Pending failed dependencies must compact before their terminal fact can be released. + return false + } else if (settlement.failed) { + return false + } else if (release.outcome === 'exited') { + if (!settlement.noChild) { + return false + } + } else if ( + !release.incarnationId || + settlement.result?.incarnationId !== release.incarnationId + ) { + return false + } + } + retained.delete(key) + if (!('outcome' in entry) && entry.settlement) { + entry.settlement.result = undefined + entry.settlement.reconcile = undefined + entry.settlement.notifyExit = undefined + entry.settlement.preparationWork = undefined + entry.settlement.physicalCompletion = undefined + } + return true +} + +export function compactDaemonSpawnPreparation( + retained: Map, + sessionId: string, + preparation: PendingPtySpawnPreparation +): void { + const settlement = preparation.settlement + if (!settlement) { + return + } + if (!settlement.failed) { + settlement.noChild = true + } + void Promise.all([ + settlement.completion, + settlement.failed ? settlement.physicalCompletion : undefined + ]).then(() => { + const anchor = preparation.switchAnchor + const key = JSON.stringify([sessionId, preparation.clientId, anchor?.token]) + if (!anchor || retained.get(key) !== preparation || !settlement.producerFinished) { + return + } + retained.set(key, { + outcome: settlement.failed ? 'failed' : 'exited', + clientId: preparation.clientId, + requestId: preparation.requestId, + anchor: anchor.token, + invocationId: anchor.invocationId, + ...(settlement.result?.incarnationId + ? { incarnationId: settlement.result.incarnationId } + : {}) + }) + settlement.result = undefined + settlement.reconcile = undefined + settlement.notifyExit = undefined + settlement.preparationWork = undefined + settlement.physicalCompletion = undefined + }) +} diff --git a/src/main/daemon/daemon-pty-spawn-preparation-state.ts b/src/main/daemon/daemon-pty-spawn-preparation-state.ts new file mode 100644 index 000000000000..f0a359ccf5ef --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-preparation-state.ts @@ -0,0 +1,32 @@ +import type { CreateOrAttachResult } from './types' +export type PendingPtySpawnPreparation = { + canceled: boolean + switchAnchor?: { token: string; invocationId: string; at: number; deadline: number } + // Unlike the polled canceled flag, the signal interrupts in-flight cwd probes on dead shares. + readonly controller: AbortController + settlement?: { + completion: Promise + finish: () => void + notifyExit?: (result: CreateOrAttachResult, code: number) => void + result?: CreateOrAttachResult + noChild: boolean + failed?: true + preparationWork?: Promise + physicalCompletion?: Promise + reconcile?: () => Promise + producerFinished?: boolean + } + cancelTimer?: ReturnType + // Session IDs can be shared; disconnect cancels only this client's preparations. + clientId: string + requestId: string +} + +export type CompletedPreparation = { + outcome: 'exited' | 'failed' + clientId: string + requestId: string + anchor: string + invocationId: string + incarnationId?: string +} diff --git a/src/main/daemon/daemon-pty-spawn-preparations.test.ts b/src/main/daemon/daemon-pty-spawn-preparations.test.ts index 8d33bdec6f02..b8e118e0aee0 100644 --- a/src/main/daemon/daemon-pty-spawn-preparations.test.ts +++ b/src/main/daemon/daemon-pty-spawn-preparations.test.ts @@ -9,6 +9,7 @@ it('anchors the commit on receiver preparation, so network and queued delay cons const receipt = preparations.prepareSwitch('pane', 'connection', 'prepare', 'click') now = 20 const commit = preparations.commitSwitch('pane', 'connection', 'commit', { + contract: 'creation-settlement-v1', invocationId: 'click', anchor: receipt.anchor, remainingMs: 100 @@ -22,7 +23,12 @@ it('anchors the commit on receiver preparation, so network and queued delay cons it('rejects another connection and a reused anchor while the actual selected preparation remains owned', () => { const preparations = new DaemonPtySpawnPreparations(async () => {}) const receipt = preparations.prepareSwitch('pane', 'connection', 'prepare', 'click') - const execution = { invocationId: 'click', anchor: receipt.anchor, remainingMs: 1000 } + const execution = { + contract: 'creation-settlement-v1', + invocationId: 'click', + anchor: receipt.anchor, + remainingMs: 1000 + } expect(() => preparations.commitSwitch('pane', 'different', 'commit', execution)).toThrow() const selected = preparations.commitSwitch('pane', 'connection', 'commit', execution) expect(() => preparations.commitSwitch('pane', 'connection', 'duplicate', execution)).toThrow() diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts index 88421345e9c5..25f0a4fc1d2c 100644 --- a/src/main/daemon/daemon-pty-spawn-preparations.ts +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -1,17 +1,28 @@ -import { TerminalAttachCanceledError } from './types' - -export type PendingPtySpawnPreparation = { - canceled: boolean - switchAnchor?: { token: string; invocationId: string; at: number; deadline: number } - // Unlike the polled canceled flag, the signal interrupts in-flight cwd probes on dead shares. - readonly controller: AbortController - cancelTimer?: ReturnType - // Session IDs can be shared; disconnect cancels only this client's preparations. - clientId: string - requestId: string -} +import { + compactDaemonSpawnPreparation, + releaseDaemonSpawnPreparation +} from './daemon-pty-spawn-preparation-release' +import type { + PendingPtySpawnPreparation, + CompletedPreparation +} from './daemon-pty-spawn-preparation-state' +import { + TerminalAttachCanceledError, + type CreateOrAttachResult, + type CreateOrAttachSettlement +} from './types' +export { + type PendingPtySpawnPreparation, + type CompletedPreparation +} from './daemon-pty-spawn-preparation-state' export class DaemonPtySpawnPreparations { + private readonly retained = new Map() + + hasOwedOutcomes(): boolean { + return this.retained.size > 0 + } + private readonly pending = new Map>() private readonly cancellationByPreparation = new WeakMap< PendingPtySpawnPreparation, @@ -55,6 +66,13 @@ export class DaemonPtySpawnPreparations { prepareSwitch(sessionId: string, clientId: string, requestId: string, invocationId: string) { const preparation = this.register(sessionId, clientId, requestId, undefined) + const completion = Promise.withResolvers() + preparation.settlement = { + completion: completion.promise, + finish: completion.resolve, + noChild: true + } + this.retained.set(JSON.stringify([sessionId, clientId, requestId]), preparation) preparation.switchAnchor = { token: requestId, invocationId, @@ -66,14 +84,14 @@ export class DaemonPtySpawnPreparations { this.finish(sessionId, preparation) }, 720_000) preparation.cancelTimer.unref?.() - return { anchor: requestId } + return { anchor: requestId, retention: 'creation-settlement-v1' as const } } commitSwitch( sessionId: string, clientId: string, requestId: string, - execution: { invocationId: string; anchor?: string; remainingMs?: number } + execution: { invocationId: string; anchor?: string; remainingMs?: number; contract?: string } ): PendingPtySpawnPreparation { const preparation = [...(this.pending.get(sessionId) ?? [])].find( (entry) => @@ -82,24 +100,28 @@ export class DaemonPtySpawnPreparations { entry.switchAnchor?.invocationId === execution.invocationId ) const anchor = preparation?.switchAnchor + if (!preparation || !anchor || anchor.deadline !== Infinity) { + throw new TerminalAttachCanceledError(sessionId) + } + preparation.requestId = requestId if ( - !preparation || - !anchor || + execution.contract !== 'creation-settlement-v1' || preparation.canceled || !Number.isFinite(execution.remainingMs) || execution.remainingMs! <= 0 || - execution.remainingMs! > 720_000 || - anchor.deadline !== Infinity + execution.remainingMs! > 720_000 ) { + this.cancelPreparation(preparation) + this.finish(sessionId, preparation) throw new TerminalAttachCanceledError(sessionId) } anchor.deadline = anchor.at + execution.remainingMs! - preparation.requestId = requestId if (performance.now() >= anchor.deadline) { this.cancelPreparation(preparation) this.finish(sessionId, preparation) throw new TerminalAttachCanceledError(sessionId) } + preparation.settlement!.noChild = false if (preparation.cancelTimer) { clearTimeout(preparation.cancelTimer) } @@ -125,9 +147,11 @@ export class DaemonPtySpawnPreparations { ): Promise { // Return protocol cancellation before shutdown tears down the client transport. const cancellation = this.cancellationByPreparation.get(preparation) - const preparationTask = cancellation - ? Promise.race([this.preparePtySpawn(), cancellation.promise]) - : this.preparePtySpawn() + const work = this.preparePtySpawn() + if (preparation.settlement) { + preparation.settlement.preparationWork = work + } + const preparationTask = cancellation ? Promise.race([work, cancellation.promise]) : work await preparationTask if (this.isCanceled(preparation)) { throw new TerminalAttachCanceledError(sessionId) @@ -139,6 +163,18 @@ export class DaemonPtySpawnPreparations { clearTimeout(preparation.cancelTimer) } this.cancellationByPreparation.delete(preparation) + if (preparation.settlement) { + const settlement = preparation.settlement + void Promise.resolve(settlement.preparationWork) + .catch(() => {}) + .then(() => { + settlement.producerFinished = true + settlement.finish() + if (settlement.noChild || settlement.failed) { + this.retireSettled(sessionId, preparation) + } + }) + } const pendingForSession = this.pending.get(sessionId) pendingForSession?.delete(preparation) if (pendingForSession?.size === 0) { @@ -166,6 +202,94 @@ export class DaemonPtySpawnPreparations { return canceled } + async settle( + sessionId: string, + clientId: string, + requestId: string | undefined, + identity: { anchor: string; invocationId: string }, + notifyExit?: (result: CreateOrAttachResult, code: number) => void + ): Promise { + const key = JSON.stringify([sessionId, clientId, identity.anchor]) + const preparation = this.retained.get(key) + if (preparation && 'outcome' in preparation) { + return preparation.requestId === requestId && + preparation.invocationId === identity.invocationId + ? { outcome: preparation.outcome } + : { outcome: 'unknown' } + } + const preparedOnly = + preparation?.switchAnchor?.deadline === Infinity && preparation.requestId === identity.anchor + if ( + !preparation || + typeof requestId !== 'string' || + (!preparedOnly && preparation.requestId !== requestId) || + preparation.switchAnchor?.invocationId !== identity.invocationId || + !preparation.settlement + ) { + return { outcome: 'unknown' } + } + this.cancelPreparation(preparation) + if (preparedOnly) { + preparation.requestId = requestId + this.finish(sessionId, preparation) + } + const settlement = preparation.settlement + settlement.notifyExit = notifyExit + await settlement.completion + if (!settlement.noChild) { + await settlement.reconcile?.() + } + if (settlement.failed) { + await settlement.physicalCompletion + return { outcome: 'failed' } + } + if (settlement.noChild) { + return { outcome: 'exited' } + } + return settlement.result + ? { outcome: 'owned', result: settlement.result } + : { outcome: 'unknown' } + } + + trackPhysical( + sessionId: string, + preparation: PendingPtySpawnPreparation, + completion: Promise, + exitCode?: () => number | null + ): void { + if (!preparation.settlement) { + return + } + preparation.settlement.physicalCompletion = completion + if (!exitCode) { + preparation.settlement.failed = true + } + const reference = new WeakRef(preparation) + void completion.then(() => { + const current = reference.deref() + if (!current) { + return + } + const result = current.settlement?.result + if (result && exitCode) { + current.settlement?.notifyExit?.(result, exitCode() ?? -1) + } + this.retireSettled(sessionId, current) + }) + } + + retireSettled(sessionId: string, preparation: PendingPtySpawnPreparation): void { + compactDaemonSpawnPreparation(this.retained, sessionId, preparation) + } + + release( + ...args: Parameters extends [unknown, ...infer A] + ? A + : never + ): boolean { + return releaseDaemonSpawnPreparation(this.retained, ...args) + } + cancelForClient(clientId: string): void { for (const pendingForSession of this.pending.values()) { for (const preparation of pendingForSession) { diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index fc7271d51100..9ad869086246 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -1,14 +1,18 @@ +import type { DaemonPtySpawnContext } from './daemon-pty-spawn-context' +import { DaemonPtySpawnSettlement } from './daemon-pty-spawn-settlement' +import { + SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION +} from './daemon-protocol-version' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ColdRestorePayload } from './cold-restore-payload-cache' import type { DaemonAuditObservation } from './daemon-audit-classifier' -import { - DaemonPtyRuntimeState, - type HistoryRecoveryContext, - type PendingDaemonSpawnOperation, - type SnapshotCheckpointResult +import type { + PendingDaemonSpawnOperation, + SnapshotCheckpointResult } from './daemon-pty-runtime-state' +import type { DaemonClient } from './client' import { isDaemonGoneError } from './daemon-endpoint-errors' -import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' import type { ColdRestoreInfo } from './history-reader' import { NdjsonLineTooLongError } from './ndjson' import { @@ -17,36 +21,17 @@ import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' import type { CreateOrAttachResult, GetSnapshotResult } from './types' -import type { PtySpawnOptions, PtySpawnResult } from '../providers/pty-provider-contract' +import type { PtySpawnResult } from '../providers/pty-provider-contract' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { isMissingWorkingDirectoryError } from '../providers/working-directory-validation' +export { type DaemonPtySpawnContext } from './daemon-pty-spawn-context' /** A daemon kept running across an update trusts `interactive` even for a claimed resume. */ function forwardedSpawnIntent(intent: SpawnIntent, claim: unknown): SpawnIntent { return intent === 'interactive' && claim !== undefined ? 'automatic-restore' : intent } -export type DaemonPtySpawnContext = { - opts: PtySpawnOptions - operation: PendingDaemonSpawnOperation - historyRecovery: HistoryRecoveryContext - requestedSessionId: string - attachOnly: boolean - emulateLegacyAttachOnly: boolean - sessionId: string - wslDistro: string | undefined - restoreInfo: ColdRestoreInfo | null - restoreSkippedForLiveSession: boolean - effectiveCwd: string | undefined - effectiveCols: number - effectiveRows: number - shellReadySupported: boolean - shellReadyTimeoutMs: number | undefined - historySeedSegments: readonly string[] | null - detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise -} - -export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { +export abstract class DaemonPtySpawnRequest extends DaemonPtySpawnSettlement { protected abstract takeSnapshotAndCheckpoint( sessionId: string, opts: { @@ -106,55 +91,137 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { context: DaemonPtySpawnContext, historySeedSegments: readonly string[] | null ): Promise { - const requestCreateOrAttach = ( + let anchor: string | undefined + const requestCreateOrAttach = async ( historySeed: string | undefined, historySeedTransferId: string | undefined ) => { const { opts } = context - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const payload = { - sessionId: context.sessionId, - cols: context.effectiveCols, - rows: context.effectiveRows, - cwd: context.attachOnly ? undefined : context.effectiveCwd, - env: context.attachOnly ? undefined : opts.env, - envToDelete: context.attachOnly ? undefined : opts.envToDelete, - command: context.attachOnly ? undefined : opts.command, - startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, - launchAgent: context.attachOnly ? undefined : opts.launchAgent, - ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), - shellOverride: context.attachOnly ? undefined : opts.shellOverride, - terminalShellArgs: context.attachOnly ? undefined : opts.terminalShellArgs, - terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: context.attachOnly - ? undefined - : opts.terminalWindowsPowerShellImplementation, - shellReadySupported: context.attachOnly ? false : context.shellReadySupported, - ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } - : {}), - ...(historySeed ? { historySeed } : {}), - ...(historySeedTransferId ? { historySeedTransferId } : {}), - ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress - ? { startupIngress: opts.startupIngress } - : {}), - ...(!context.attachOnly && opts.spawnIntent - ? { spawnIntent: forwardedSpawnIntent(opts.spawnIntent, opts.agentSessionEnsure) } - : {}), - ...(!context.attachOnly && opts.agentSessionEnsure - ? { agentSessionEnsure: opts.agentSessionEnsure } - : {}) - } - return opts.signal - ? this.client.request( + let strong = Boolean( + opts.switchExecution && this.protocolVersion >= SESSION_SWITCH_PHYSICAL_PROTOCOL_VERSION + ) + let preparedEndpoint: ReturnType = null + const observeDispatch = (requestId: string, contract = 'creation-settlement-v1') => + this.observeSwitchSpawn(context, requestId, anchor, preparedEndpoint, contract) + + try { + if (context.operation.switchSettlement) { + throw new Error('Terminal creation was interrupted; try again') + } + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + opts.switchExecution?.check() + if (opts.switchExecution && strong && !anchor) { + preparedEndpoint = this.client.getDaemonIdentity() + const prepared = await this.client.request<{ anchor: string; retention?: string }>( 'createOrAttach', - payload, - undefined, + { + sessionId: context.sessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + switchExecution: { phase: 'prepare', invocationId: opts.switchExecution.invocationId } + }, + Math.max(1, opts.switchExecution.remaining()), opts.signal ) - : this.client.request('createOrAttach', payload) + if ( + prepared.retention !== 'creation-settlement-v1' || + typeof prepared.anchor !== 'string' || + !prepared.anchor + ) { + // Negotiate before destination dispatch; an unaware peer keeps its ordinary route. + if (typeof prepared.anchor === 'string' && prepared.anchor) { + if (prepared.retention === 'consumer-release-v1' && preparedEndpoint) { + // Known old prepared-only cleanup uses the same exact owner, never a strong commit. + anchor = prepared.anchor + observeDispatch(anchor, 'consumer-release-v1') + await context.operation.switchSettlement!.recover() + context.operation.switchSettlement = undefined + anchor = undefined + } else { + await this.client.request( + 'cancelCreateOrAttach', + { sessionId: context.sessionId, requestId: prepared.anchor }, + Math.max(1, opts.switchExecution.remaining()) + ) + } + } + strong = false + } + if (strong) { + anchor = prepared.anchor + } + opts.switchExecution.check() + } + const payload = { + ...(opts.switchExecution && strong + ? { + switchExecution: { + phase: 'commit' as const, + contract: 'creation-settlement-v1' as const, + invocationId: opts.switchExecution.invocationId, + anchor, + remainingMs: opts.switchExecution.remaining() + } + } + : {}), + sessionId: context.sessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + cwd: context.attachOnly ? undefined : context.effectiveCwd, + env: context.attachOnly ? undefined : opts.env, + envToDelete: context.attachOnly ? undefined : opts.envToDelete, + command: context.attachOnly ? undefined : opts.command, + startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + launchAgent: context.attachOnly ? undefined : opts.launchAgent, + ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), + shellOverride: context.attachOnly ? undefined : opts.shellOverride, + terminalShellArgs: context.attachOnly ? undefined : opts.terminalShellArgs, + terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: context.attachOnly + ? undefined + : opts.terminalWindowsPowerShellImplementation, + shellReadySupported: context.attachOnly ? false : context.shellReadySupported, + ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } + : {}), + ...(historySeed ? { historySeed } : {}), + ...(historySeedTransferId ? { historySeedTransferId } : {}), + ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress + ? { startupIngress: opts.startupIngress } + : {}), + ...(!context.attachOnly && opts.spawnIntent + ? { spawnIntent: forwardedSpawnIntent(opts.spawnIntent, opts.agentSessionEnsure) } + : {}), + ...(!context.attachOnly && opts.agentSessionEnsure + ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}) + } + const result = await (opts.switchExecution + ? this.client.request( + 'createOrAttach', + payload, + Math.max(1, opts.switchExecution.remaining()), + opts.signal, + (requestId) => + observeDispatch(requestId, strong ? 'creation-settlement-v1' : 'ordinary') + ) + : opts.signal + ? this.client.request( + 'createOrAttach', + payload, + undefined, + opts.signal + ) + : this.client.request('createOrAttach', payload)) + return result + } catch (error) { + if (anchor && !context.operation.switchSettlement) { + observeDispatch(anchor) + } + throw error + } } let historySeedUnavailable = false diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 1f02667c8a02..a7c2a0318964 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -1,5 +1,5 @@ +import { daemonPtyReattachResult } from './daemon-pty-reattach-result' import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { daemonSpawnResultIdentity } from './daemon-spawn-result-identity' import { retireUnexpectedAttachOnlySpawn } from './daemon-attach-only-retirement' import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' @@ -8,7 +8,6 @@ import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze' import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' import { SessionNotFoundError, type CreateOrAttachResult } from './types' import type { PtySpawnResult } from '../providers/pty-provider-contract' - export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { protected async finishSpawn( context: DaemonPtySpawnContext, @@ -31,14 +30,28 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { return created } let result = initialResult - const finalizeSpawnResult = (spawnResult: PtySpawnResult): PtySpawnResult => - this.resultForExitBeforeSpawnReply(sessionId, result, operation) ?? - (result.isNew && !historyRecovery.identityChanged - ? { - ...spawnResult, - startupCwdSource: effectiveCwd === opts.cwd ? 'request' : 'recovered-history' + const finalizeSpawnResult = (spawnResult: PtySpawnResult): PtySpawnResult => { + const exited = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (!result.switchOutcomeUnknown && result.incarnationId) { + operation.switchSettlement?.consume(exited ? 'exited' : 'adopted', result.incarnationId) + } + if (!result.switchOutcomeUnknown && result.incarnationId) { + for (const pending of this.pendingSpawnOperationsBySessionId.get(sessionId) ?? []) { + if (pending !== operation) { + pending.switchSettlement?.adopt?.(result.incarnationId) } - : spawnResult) + } + } + return ( + exited ?? + (result.isNew && !historyRecovery.identityChanged + ? { + ...spawnResult, + startupCwdSource: effectiveCwd === opts.cwd ? 'request' : 'recovered-history' + } + : spawnResult) + ) + } let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { const requestedSessionId = sessionId @@ -78,6 +91,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { this.ptyInventory.spawned(sessionId, result.incarnationId) const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedResult) { + operation.switchSettlement?.consume('exited') return exitedResult } if (result.incarnationId) { @@ -153,6 +167,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { this.ptyInventory.spawned(sessionId, result.incarnationId) const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedRetryResult) { + operation.switchSettlement?.consume('exited') return exitedRetryResult } if (result.incarnationId) { @@ -283,48 +298,15 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) - const reattachProviderSequence = - typeof reattachSnapshot.outputSequence === 'number' - ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } - : providerSequence - const isAltScreen = reattachSnapshot.modes.alternateScreen - const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences - const snapshotFrame = reattachSnapshot.snapshotAnsi - // Reassert kitty flags after payload screen switches; carry known zero separately from unknown. - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( - reattachSnapshot.modes.kittyKeyboardFlags + return finalizeSpawnResult( + daemonPtyReattachResult( + sessionId, + result, + pid, + providerWslDistro, + reattachSnapshot, + providerSequence + ) ) - return finalizeSpawnResult({ - id: sessionId, - ...daemonSpawnResultIdentity(result), - pid, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - snapshot: snapshotPrefix + snapshotFrame, - snapshotCols: reattachSnapshot.cols, - snapshotRows: reattachSnapshot.rows, - // Only alt frames need a separate restore; normal history can replay at its captured grid. - ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi - ? { - snapshotPrefixAnsi: snapshotPrefix, - snapshotFrameAnsi: snapshotFrame, - snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi - } - : {}), - ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), - ...(kittyKeyboardFlags !== undefined - ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } - : {}), - ...(reattachSnapshot.terminalOwner - ? { snapshotTerminalOwner: reattachSnapshot.terminalOwner } - : {}), - isReattach: true, - isAlternateScreen: isAltScreen, - // Snapshot ANSI lacks title frames; carry the title separately across relaunch. - ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), - // Replay the mid-escape tail after renderer reset so split escapes do not render literally. - ...(reattachSnapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } - : {}) - }) } } diff --git a/src/main/daemon/daemon-pty-spawn-settlement.ts b/src/main/daemon/daemon-pty-spawn-settlement.ts new file mode 100644 index 000000000000..11c523a55e6a --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-settlement.ts @@ -0,0 +1,259 @@ +import { isProvenProcessExit } from '../../shared/terminal-exit-cause' +import type { DaemonClient } from './client' +import { sameEndpointIdentity } from './daemon-endpoint-incarnation' +import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import { DaemonPtyRuntimeState, type PendingDaemonSpawnOperation } from './daemon-pty-runtime-state' +import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import type { + CancelCreateOrAttachResult, + CancelCreateOrAttachRequest, + CreateOrAttachResult +} from './types' + +export abstract class DaemonPtySpawnSettlement extends DaemonPtyRuntimeState { + protected readonly switchSpawnSettlements = new Map< + PendingDaemonSpawnOperation, + NonNullable + >() + private readonly switchReleaseCleanup = new Map< + string, + { + client: DaemonClient + payload: CancelCreateOrAttachRequest['payload'] + } + >() + private switchReleaseDrain: Promise | undefined + + protected async reconcileSwitchSpawns(): Promise { + await Promise.all([...this.switchSpawnSettlements.values()].map((owner) => owner.recover())) + } + + async settleSwitchWorkForReplacement(): Promise { + await this.reconcileSwitchSpawns() + await Promise.all([...this.switchSpawnSettlements.values()].map((owner) => owner.completion)) + } + + inheritSwitchReleaseCleanup(previous: DaemonPtySpawnSettlement): void { + for (const [key, entry] of previous.switchReleaseCleanup) { + this.switchReleaseCleanup.set(key, entry) + } + previous.switchReleaseCleanup.clear() + this.drainSwitchReleaseCleanup() + } + + protected drainSwitchReleaseCleanup(): void { + if (this.switchReleaseDrain || !this.switchReleaseCleanup.size) { + return + } + const batch = new Map(this.switchReleaseCleanup) + this.switchReleaseDrain = Promise.all( + [...batch].map(async ([key, entry]) => { + try { + await entry.client.withRetainedConnection(async () => { + await entry.client.ensureConnected() + const endpoint = entry.payload.release?.endpoint + const current = entry.client.getDaemonIdentity() + if (!endpoint || !current) { + return + } + if (!sameEndpointIdentity(endpoint, current)) { + // Only consumed GC debt is obsolete at an authenticated replacement endpoint. + if (this.switchReleaseCleanup.get(key) === entry) { + this.switchReleaseCleanup.delete(key) + } + return + } + const result = await entry.client.request( + 'cancelCreateOrAttach', + entry.payload, + 5000 + ) + if (result.released === true && this.switchReleaseCleanup.get(key) === entry) { + this.switchReleaseCleanup.delete(key) + } + }) + } catch { + /* Release debt never holds physical completion or user work. */ + } + }) + ) + .then(() => {}) + .finally(() => { + this.switchReleaseDrain = undefined + if ([...this.switchReleaseCleanup].some(([key, entry]) => batch.get(key) !== entry)) { + this.drainSwitchReleaseCleanup() + } + }) + } + + protected abstract ensureConnected(deadlineMs?: number): Promise + protected abstract withDaemonRetry(fn: () => Promise): Promise + + protected observeSwitchSpawn( + context: DaemonPtySpawnContext, + requestId: string, + anchor: string | undefined, + preparedEndpoint: ReturnType, + contract = 'creation-settlement-v1' + ): void { + const { opts } = context + const authority = opts.switchExecution + const endpoint = preparedEndpoint ?? this.client.getDaemonIdentity() + if (!authority || (contract !== 'ordinary' && !anchor)) { + return + } + const completion = Promise.withResolvers() + const originalAnchor = anchor + let consumed = false + const dispatchedClient = this.client + const dispatchedSessionId = context.sessionId + let recovery: Promise | undefined + let ordinaryIncarnation: string | undefined + const consume = (outcome: 'exited' | 'adopted' | 'failed', incarnationId?: string) => { + if (consumed) { + return + } + consumed = true + completion.resolve() + if (this.switchSpawnSettlements.get(context.operation) === owner) { + this.switchSpawnSettlements.delete(context.operation) + } + if (endpoint && originalAnchor && contract !== 'ordinary') { + const payload = { + sessionId: dispatchedSessionId, + requestId, + release: { + invocationId: authority.invocationId, + anchor: originalAnchor, + endpoint, + outcome, + ...(incarnationId ? { incarnationId } : {}) + } + } + this.switchReleaseCleanup.set( + JSON.stringify([endpoint, dispatchedSessionId, requestId, originalAnchor]), + { client: dispatchedClient, payload } + ) + this.drainSwitchReleaseCleanup() + } + } + const recover = (): Promise => { + if (consumed) { + return owner.completion + } + if (recovery) { + return recovery + } + recovery = this.withDaemonRetry(async () => { + await this.ensureConnected() + if (consumed) { + return + } + const current = this.client.getDaemonIdentity() + if (!endpoint || !current || !sameEndpointIdentity(endpoint, current)) { + throw new Error('Terminal owner unavailable; reconnect and try again') + } + if (contract === 'ordinary') { + const exited = + ordinaryIncarnation && + (context.operation.exitsBySessionId.get(dispatchedSessionId) ?? []).some( + (exit) => exit.incarnationId === ordinaryIncarnation && isProvenProcessExit(exit.code) + ) + if (exited) { + consume('exited') + return + } + if (this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) { + throw new Error('Terminal owner unavailable; reconnect and try again') + } + const result = await this.client.request( + 'createOrAttach', + { + sessionId: dispatchedSessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + attachOnly: true + }, + 5000 + ) + if (consumed) { + return + } + if ( + result.isNew || + !result.incarnationId || + (ordinaryIncarnation && ordinaryIncarnation !== result.incarnationId) + ) { + throw new Error('Terminal owner has not confirmed physical completion; try again') + } + ordinaryIncarnation = result.incarnationId + this.sessionIncarnations.set(dispatchedSessionId, ordinaryIncarnation) + this.activeSessionIds.add(dispatchedSessionId) + // Attach observes the exact live resource; only ordinary adoption or its positive exit settles it. + throw new Error('Terminal is still running; open its tab or close it and try again') + } + if (!originalAnchor) { + throw new Error('Terminal owner unavailable; reconnect and try again') + } + const response = await this.client.request( + 'cancelCreateOrAttach', + { + sessionId: dispatchedSessionId, + requestId, + settlement: { + invocationId: authority.invocationId, + anchor: originalAnchor, + endpoint + } + }, + 5000 + ) + if (consumed) { + return + } + if (response.retention !== contract) { + throw new Error('Terminal owner has not confirmed physical completion; try again') + } + if ( + response.settlement?.outcome === 'exited' || + (contract === 'creation-settlement-v1' && response.settlement?.outcome === 'failed') + ) { + consume(response.settlement.outcome) + return + } + if (response.settlement?.outcome === 'owned') { + const result = response.settlement.result + const id = result.agentSessionEnsure?.owner.ptyId ?? dispatchedSessionId + // Publish the actual surviving incarnation through ordinary adapter inventory. + if (result.incarnationId) { + this.sessionIncarnations.set(id, result.incarnationId) + } + this.activeSessionIds.add(id) + } + throw new Error('Terminal owner has not confirmed physical completion; try again') + }) + .catch((error) => { + if (!consumed) { + throw error + } + }) + .finally(() => { + recovery = undefined + }) + return recovery + } + const owner = { + completion: completion.promise, + recover, + consume, + adopt: (incarnationId: string) => { + if (contract === 'ordinary' && ordinaryIncarnation === incarnationId) { + consume('adopted', incarnationId) + } + } + } + context.operation.switchSettlement = owner + this.switchSpawnSettlements.set(context.operation, owner) + authority.trackPhysical?.(owner.completion, recover) + } +} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 39bcdf63bedd..13016337e721 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -1,3 +1,4 @@ +import { cancelDaemonCreateOrAttach } from './daemon-create-or-attach-cancellation' import { handleDaemonTerminalKill } from './daemon-terminal-kill' import { handleDaemonFencedTermination } from './daemon-fenced-termination' import { performance } from 'node:perf_hooks' @@ -15,8 +16,7 @@ import { DaemonHealthRequests } from './daemon-health-requests' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' - -type DaemonRequestRouterOptions = { +export type DaemonRequestRouterOptions = { host: TerminalHost connections: DaemonClientConnections lifecycle: DaemonServerLifecycle @@ -65,14 +65,7 @@ export class DaemonRequestRouter { case 'createOrAttach': return this.options.admission.createOrAttach(clientId, request) case 'cancelCreateOrAttach': - return { - canceled: this.options.preparations.cancel(request.payload.sessionId, { - clientId, - ...(typeof request.payload.requestId === 'string' - ? { requestId: request.payload.requestId } - : {}) - }) - } + return cancelDaemonCreateOrAttach(this.options, clientId, request) case 'closeStartupQueryAuthority': return { appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) @@ -124,6 +117,9 @@ export class DaemonRequestRouter { } case 'inspectProcess': { const options = { + ...(request.payload.captureSwitchWitness + ? { captureSwitchWitness: request.payload.captureSwitchWitness } + : {}), ...(request.payload.expectedIncarnationId ? { expectedIncarnationId: request.payload.expectedIncarnationId } : {}), @@ -218,6 +214,7 @@ export class DaemonRequestRouter { client.streamSocket !== null && this.options.connections.size === 1 && this.options.admission.inFlight === 0 && + !this.options.preparations.hasOwedOutcomes() && this.options.host.listSessions().length === 0 && this.options.connections.hasOnlyTransportsFor(client) if (retiring) { diff --git a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts index 3a5a1377ddd4..6837f9ef613a 100644 --- a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts +++ b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts @@ -1,4 +1,5 @@ import { registerDaemonCreationFailureTests } from './daemon-server-creation-failure.test-fixture' +import type * as LocalPtyUtils from '../providers/local-pty-utils' import './mock-descendant-sweep' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,7 +8,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DaemonClient } from './client' import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { DaemonServer } from './daemon-server' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import * as transport from './daemon-client-socket-connect' +import type { DaemonClientConnections } from './daemon-client-connections' import { getDaemonSocketPath } from './daemon-spawner' +const nativePty = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node-pty', () => nativePty) + +vi.mock('../providers/local-pty-utils', async (importOriginal) => ({ + ...(await importOriginal()), + ensureNodePtySpawnHelperExecutable: () => {}, + getNodePtySpawnHelperCandidates: () => [process.execPath] +})) type DaemonServerInternals = { preparations: { pending: Map> @@ -16,13 +28,14 @@ type DaemonServerInternals = { } describe('daemon async spawn cancellation', () => { - let spawned = 0 let directory: string let server: DaemonServer let client: DaemonClient let releaseSpawn: () => void let spawnStarted: Promise let subprocess: ReturnType + let preparation: Promise + let spawned: number beforeEach(async () => { spawned = 0 @@ -34,11 +47,14 @@ describe('daemon async spawn cancellation', () => { const spawnGate = new Promise((resolve) => { releaseSpawn = resolve }) + preparation = Promise.resolve() + spawned = 0 server = new DaemonServer({ socketPath: getDaemonSocketPath(directory), tokenPath: join(directory, 'daemon.token'), + preparePtySpawn: () => preparation, spawnSubprocess: async () => { - spawned++ + spawned += 1 subprocess = createMockSubprocess() markSpawnStarted() await spawnGate @@ -61,24 +77,116 @@ describe('daemon async spawn cancellation', () => { rmSync(directory, { recursive: true, force: true }) }) - registerDaemonCreationFailureTests({ - get server() { - return server - }, - get client() { - return client - }, - get subprocess() { - return subprocess - }, - get spawnStarted() { - return spawnStarted - }, - get spawned() { - return spawned + it.each(['healthy', 'release-reply-lost'] as const)( + 'consumes only after actual adoption and reclaims without a physical hold: %s', + async (loss) => { + const adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(directory), + tokenPath: join(directory, 'daemon.token') + }) + let owner: { work: Promise; recover: () => Promise } | undefined + let blocked = false + let releaseReplyDropped = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inspect only this fixture's receiver connections. + const receiver = server as unknown as { + connections: DaemonClientConnections + } + const connect = transport.connectDaemonSocket + vi.spyOn(transport, 'connectDaemonSocket').mockImplementation(async (...args) => { + if (blocked) { + throw new Error('Connection lost') + } + const socket = await connect(...args) + const write = socket.write.bind(socket) + vi.spyOn(socket, 'write').mockImplementation( + (...values: Parameters) => { + const frame = JSON.parse(String(values[0])) + if (frame.payload?.switchExecution?.phase === 'commit') { + const peer = [...receiver.connections.values()].at(-1)?.controlSocket + if (!peer) { + throw new Error('fixture control missing') + } + const respond = peer.write.bind(peer) + vi.spyOn(peer, 'write').mockImplementation( + (...bytes: Parameters) => { + const result = JSON.parse(String(bytes[0])) + if ( + loss === 'release-reply-lost' && + !releaseReplyDropped && + result.payload?.released === true + ) { + releaseReplyDropped = true + blocked = true + peer.destroy() + return true + } + return respond(...bytes) + } + ) + } + return write(...values) + } + ) + return socket + }) + const starting = adapter.spawn({ + sessionId: 'receipt-pane', + cols: 80, + rows: 24, + switchExecution: { + invocationId: 'adoption-click', + signal: new AbortController().signal, + remaining: () => 60_000, + check: () => {}, + trackPhysical: (work, recover) => { + if (recover) { + owner = { work, recover } + } + return work + } + } + }) + const observed = starting.then( + (result) => ({ result }), + (error: unknown) => ({ error }) + ) + try { + await spawnStarted + releaseSpawn() + expect(await observed).toMatchObject({ result: { id: 'receipt-pane' } }) + if (!owner) { + throw new Error('receiver completion was not tracked') + } + await owner.work + if (loss !== 'healthy') { + await vi.waitFor(() => expect(blocked).toBe(true)) + blocked = false + await adapter.listProcesses() + await expect( + adapter.spawn({ sessionId: 'receipt-pane', cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id: 'receipt-pane', isReattach: true }) + } + expect(server.host.listSessions()).toMatchObject([ + { sessionId: 'receipt-pane', isAlive: true } + ]) + expect(subprocess.forceKill).not.toHaveBeenCalled() + expect(spawned).toBe(1) + const exit = vi.fn() + adapter.onExit(exit) + subprocess._simulateExit(0) + await vi.waitFor(() => expect(exit).toHaveBeenCalledOnce()) + } finally { + blocked = false + releaseSpawn() + await observed + subprocess?._simulateExit(0) + await owner?.recover().catch(() => {}) + await owner?.work + adapter.dispose() + } }, - releaseSpawn: () => releaseSpawn() - }) + 15_000 + ) it.each(['cancelCreateOrAttach', 'kill'] as const)( 'reaps a subprocess canceled by %s during async spawn', @@ -158,59 +266,6 @@ describe('daemon async spawn cancellation', () => { await expect(client.request('listSessions', undefined)).resolves.toEqual({ sessions: [] }) }) - it('matches a cancel for an attach-only request queued behind a hung create', async () => { - const create = client.request('createOrAttach', { - sessionId: 'shared-session', - cols: 80, - rows: 24 - }) - await spawnStarted - const abort = new AbortController() - const attach = client.request( - 'createOrAttach', - { sessionId: 'shared-session', cols: 80, rows: 24, attachOnly: true }, - 30_000, - abort.signal - ) - const daemon = server as unknown as DaemonServerInternals - // Attach-only preparations must remain registered so cancellation can find them. - await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) - - abort.abort() - await expect(attach).rejects.toThrow('client_disconnected') - - releaseSpawn() - await expect(create).resolves.toMatchObject({ isNew: true }) - }) - - it('does not cancel a sibling request for the same session id', async () => { - const first = client.request('createOrAttach', { - sessionId: 'shared-session', - cols: 80, - rows: 24 - }) - const abort = new AbortController() - const second = client.request( - 'createOrAttach', - { sessionId: 'shared-session', cols: 80, rows: 24 }, - 30_000, - abort.signal - ) - await spawnStarted - const daemon = server as unknown as DaemonServerInternals - await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) - - abort.abort() - await expect(second).rejects.toThrow('client_disconnected') - releaseSpawn() - - await expect(first).resolves.toMatchObject({ isNew: true }) - expect(subprocess.forceKill).not.toHaveBeenCalled() - await expect(client.request('listSessions', undefined)).resolves.toMatchObject({ - sessions: [expect.objectContaining({ sessionId: 'shared-session' })] - }) - }) - it('reaps an async spawn before daemon shutdown completes', async () => { const create = client .request('createOrAttach', { @@ -229,4 +284,31 @@ describe('daemon async spawn cancellation', () => { expect(subprocess.forceKill).toHaveBeenCalledOnce() expect(subprocess.dispose).toHaveBeenCalledOnce() }) + registerDaemonCreationFailureTests({ + nativePty, + get directory() { + return directory + }, + get server() { + return server + }, + set server(value) { + server = value + }, + get client() { + return client + }, + get subprocess() { + return subprocess + }, + get releaseSpawn() { + return releaseSpawn + }, + get spawnStarted() { + return spawnStarted + }, + get spawned() { + return spawned + } + }) }) diff --git a/src/main/daemon/daemon-server-creation-failure.test-fixture.ts b/src/main/daemon/daemon-server-creation-failure.test-fixture.ts index 6433a5564a44..07fd2fb12af2 100644 --- a/src/main/daemon/daemon-server-creation-failure.test-fixture.ts +++ b/src/main/daemon/daemon-server-creation-failure.test-fixture.ts @@ -1,17 +1,29 @@ +import { registerDaemonNativeFailureTests } from './daemon-server-native-failure.test-fixture' import './mock-descendant-sweep' +import { join } from 'node:path' import { expect, it, vi } from 'vitest' import type { DaemonClient } from './client' import type { createMockSubprocess } from './daemon-pty-adapter-test-harness' import type { DaemonServer } from './daemon-server' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import * as transport from './daemon-client-socket-connect' +import type { DaemonClientConnections } from './daemon-client-connections' +import { getDaemonSocketPath } from './daemon-spawner' +type DaemonServerInternals = { + preparations: { pending: Map> } +} export type DaemonCreationFailureFixture = { + directory: string server: DaemonServer client: DaemonClient subprocess: ReturnType releaseSpawn: () => void spawnStarted: Promise spawned: number + nativePty: { spawn: ReturnType } } export function registerDaemonCreationFailureTests(fixture: DaemonCreationFailureFixture) { + registerDaemonNativeFailureTests(fixture) it.each([ ['observer', 'retry'], ['constructor', 'retry'], @@ -89,4 +101,145 @@ export function registerDaemonCreationFailureTests(fixture: DaemonCreationFailur expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(0) } ) + + it.each(['authority', 'canceled-before-send'] as const)( + 'finishes a known successful prepare when %s refuses before commit', + async (failure) => { + const adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(fixture.directory), + tokenPath: join(fixture.directory, 'daemon.token') + }) + const abort = new AbortController() + let checks = 0 + let owner: { work: Promise; recover: () => Promise } | undefined + const frames: { + type: string + payload?: { switchExecution?: { phase: string }; settlement?: unknown; release?: unknown } + }[] = [] + const connect = transport.connectDaemonSocket + vi.spyOn(transport, 'connectDaemonSocket').mockImplementation(async (...args) => { + const socket = await connect(...args) + const write = socket.write.bind(socket) + vi.spyOn(socket, 'write').mockImplementation( + (...bytes: Parameters) => { + frames.push(JSON.parse(String(bytes[0]))) + return write(...bytes) + } + ) + return socket + }) + try { + await expect( + adapter.spawn({ + sessionId: 'known-prepared', + cols: 80, + rows: 24, + signal: abort.signal, + switchExecution: { + invocationId: 'abandoned-click', + signal: abort.signal, + remaining: () => 60_000, + check: () => { + if (++checks !== 2) { + return + } + if (failure === 'authority') { + throw new Error('fixture newer authority') + } + abort.abort() + }, + trackPhysical: (work, recover) => { + if (recover) { + owner = { work, recover } + } + return work + } + } + }) + ).rejects.toThrow(failure === 'authority' ? 'fixture newer authority' : /abort|disconnect/i) + if (!owner) { + throw new Error('known preparation lost its ordinary owner') + } + await owner.work + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inspect only this fixture receiver's actual outcome owner. + const receiver = fixture.server as unknown as { + preparations: { hasOwedOutcomes(): boolean } + connections: DaemonClientConnections + } + await vi.waitFor(() => expect(receiver.preparations.hasOwedOutcomes()).toBe(false)) + expect( + frames.filter((frame) => frame.payload?.switchExecution?.phase === 'prepare') + ).toHaveLength(1) + expect( + frames.filter((frame) => frame.payload?.switchExecution?.phase === 'commit') + ).toHaveLength(0) + expect(frames.some((frame) => frame.payload?.settlement)).toBe(true) + expect(frames.some((frame) => frame.payload?.release)).toBe(true) + expect(fixture.spawned).toBe(0) + await owner.recover() + adapter.dispose() + await vi.waitFor(() => expect(receiver.connections.size).toBe(1)) + expect(await fixture.client.request('shutdownIfIdle', undefined)).toMatchObject({ + retiring: true + }) + } finally { + adapter.dispose() + } + } + ) + it('matches a cancel for an attach-only request queued behind a hung create', async () => { + const create = fixture.client.request('createOrAttach', { + sessionId: 'shared-session', + cols: 80, + rows: 24 + }) + await fixture.spawnStarted + const abort = new AbortController() + const attach = fixture.client.request( + 'createOrAttach', + { sessionId: 'shared-session', cols: 80, rows: 24, attachOnly: true }, + 30_000, + abort.signal + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inspect only the fixture receiver pending cancellation registrations. + const daemon = fixture.server as unknown as { + preparations: { pending: Map> } + } + // Attach-only preparations must remain registered so cancellation can find them. + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) + + abort.abort() + await expect(attach).rejects.toThrow('client_disconnected') + + fixture.releaseSpawn() + await expect(create).resolves.toMatchObject({ isNew: true }) + }) + it('does not cancel a sibling request for the same session id', async () => { + const first = fixture.client.request('createOrAttach', { + sessionId: 'shared-session', + cols: 80, + rows: 24 + }) + const abort = new AbortController() + const second = fixture.client.request( + 'createOrAttach', + { sessionId: 'shared-session', cols: 80, rows: 24 }, + 30_000, + abort.signal + ) + await fixture.spawnStarted + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inspect only the fixture receiver pending map after its real spawn admission. + const daemon = fixture.server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) + + abort.abort() + await expect(second).rejects.toThrow('client_disconnected') + fixture.releaseSpawn() + + await expect(first).resolves.toMatchObject({ isNew: true }) + expect(fixture.subprocess.forceKill).not.toHaveBeenCalled() + await expect(fixture.client.request('listSessions', undefined)).resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionId: 'shared-session' })] + }) + }) } diff --git a/src/main/daemon/daemon-server-native-failure.test-fixture.ts b/src/main/daemon/daemon-server-native-failure.test-fixture.ts new file mode 100644 index 000000000000..6d39e8ecd91b --- /dev/null +++ b/src/main/daemon/daemon-server-native-failure.test-fixture.ts @@ -0,0 +1,73 @@ +import './mock-descendant-sweep' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { createPtySubprocess } from './pty-subprocess' +import { mockPtyProcess } from './pty-subprocess-test-harness' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { DaemonCreationFailureFixture } from './daemon-server-creation-failure.test-fixture' +export function registerDaemonNativeFailureTests(fixture: DaemonCreationFailureFixture) { + it('ordinary authenticated native throw releases its exact logical claim/cap before same-ID retry and delivery', async () => { + fixture.client.disconnect() + await fixture.server.shutdown() + fixture.server = new DaemonServer({ + socketPath: getDaemonSocketPath(fixture.directory), + tokenPath: join(fixture.directory, 'daemon.token'), + spawnSubprocess: createPtySubprocess, + preparePtySpawn: async () => {}, + ptySpawnHealthCheck: async () => {} + }) + await fixture.server.start() + await fixture.client.ensureConnected() + const native = mockPtyProcess(999_999_999) + fixture.nativePty.spawn + .mockReset() + .mockImplementationOnce(() => { + throw new Error('exact native no-export failure') + }) + .mockReturnValue(native) + const payload = { + sessionId: 'failed-native-ordinary', + cols: 80, + rows: 24, + cwd: fixture.directory, + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_native' + } + } + } + try { + await expect(fixture.client.request('createOrAttach', payload)).rejects.toThrow( + 'exact native no-export failure' + ) + await vi.waitFor(() => expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(0)) + expect(fixture.server.host.listSessions()).toEqual([]) + expect(await fixture.client.request('createOrAttach', payload)).toMatchObject({ + isNew: true + }) + expect(fixture.nativePty.spawn).toHaveBeenCalledTimes(2) + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(1) + await fixture.client.request('write', { + sessionId: payload.sessionId, + data: 'new ordinary input' + }) + expect(native.write).toHaveBeenCalledWith('new ordinary input') + } finally { + // External native exit reaches the actual owner; never signal a fixture PID. + native._simulateExit(0) + await fixture.server.host.dispose() + } + expect(fixture.server.automaticWork.gate.snapshot().terminals).toBe(0) + }) +} diff --git a/src/main/daemon/daemon-server-request.ts b/src/main/daemon/daemon-server-request.ts new file mode 100644 index 000000000000..4c74f1b7b43b --- /dev/null +++ b/src/main/daemon/daemon-server-request.ts @@ -0,0 +1,38 @@ +import type { Socket } from 'node:net' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import type { DaemonRequestRouter } from './daemon-request-router' +import type { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard' +import { answerAutomaticWorkRequest } from './daemon-automatic-work-protocol' +import type { DaemonRequest } from './types' +import type { HostTerminalPolicy } from './host-terminal-policy' +import { answerTerminalLimitRequest } from './daemon-terminal-limit-protocol' +import { answerDaemonControlRequest } from './daemon-control-request' +export async function handleDaemonRequest( + options: { + automaticWork: DaemonAutomaticWorkGuard + endpoint: DaemonEndpointLifecycle + requestRouter: DaemonRequestRouter + terminalPolicy: HostTerminalPolicy + terminalPolicyNonce: string + lifecycle: DaemonServerLifecycle + }, + socket: Socket, + clientId: string, + request: DaemonRequest +): Promise { + return answerDaemonControlRequest( + socket, + request, + async () => + answerTerminalLimitRequest( + request, + options.terminalPolicy, + options.terminalPolicyNonce, + () => options.lifecycle.isAcceptingWork() && !options.endpoint.hasLostOwnership() + ) ?? + answerAutomaticWorkRequest(request, options.automaticWork, options.endpoint) ?? + options.requestRouter.route(clientId, request), + () => options.lifecycle.startPendingShutdownReply(clientId, request.id) + ) +} diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index e322b91aba96..ab168192016f 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,3 +1,4 @@ +import { handleDaemonRequest } from './daemon-server-request' import { randomUUID } from 'node:crypto' import type { Socket } from 'node:net' import { setImmediate as waitForImmediate } from 'node:timers/promises' @@ -22,12 +23,8 @@ import { checkPtySpawnHealth } from './pty-subprocess' import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import { TerminalHost } from './terminal-host' import { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard' -import { answerAutomaticWorkRequest } from './daemon-automatic-work-protocol' import { HostTerminalPolicy } from './host-terminal-policy' -import { answerTerminalLimitRequest } from './daemon-terminal-limit-protocol' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, type DaemonRequest } from './types' -import { answerDaemonControlRequest } from './daemon-control-request' - export class DaemonServer { private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 @@ -257,7 +254,11 @@ export class DaemonServer { } private isIdle(): boolean { - if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) { + if ( + this.admission.inFlight > 0 || + this.host.hasLiveSessions() || + this.preparations.hasOwedOutcomes() + ) { return false } if (this.endpoint.lost) { @@ -288,24 +289,19 @@ export class DaemonServer { this.connections.dispose() } - private async handleRequest( - socket: Socket, - clientId: string, - request: DaemonRequest - ): Promise { - return answerDaemonControlRequest( + private handleRequest(socket: Socket, clientId: string, request: DaemonRequest): Promise { + return handleDaemonRequest( + { + automaticWork: this.automaticWork, + endpoint: this.endpoint, + requestRouter: this.requestRouter, + lifecycle: this.lifecycle, + terminalPolicy: this.terminalPolicy, + terminalPolicyNonce: this.terminalPolicyNonce + }, socket, - request, - async () => - answerTerminalLimitRequest( - request, - this.terminalPolicy, - this.terminalPolicyNonce, - () => this.lifecycle.isAcceptingWork() && !this.endpoint.hasLostOwnership() - ) ?? - answerAutomaticWorkRequest(request, this.automaticWork, this.endpoint) ?? - this.requestRouter.route(clientId, request), - () => this.lifecycle.startPendingShutdownReply(clientId, request.id) + clientId, + request ) } } diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index 050b12f53131..110e6657858c 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -113,6 +113,7 @@ describe('STA-4228 keep-history stop bounds only the caller wait on the final ch rmSync(dir, { recursive: true, force: true }) }) + /** Wedges one session's checkpoint at the history-write layer until explicitly released. */ function stallCheckpointFor(stalledSessionId: string): { release: () => void } { const manager = adapter.getHistoryManager() expect(manager).not.toBeNull() diff --git a/src/main/daemon/daemon-spawn-result-identity.ts b/src/main/daemon/daemon-spawn-result-identity.ts index 3a57274ea12b..c27929f20659 100644 --- a/src/main/daemon/daemon-spawn-result-identity.ts +++ b/src/main/daemon/daemon-spawn-result-identity.ts @@ -3,8 +3,17 @@ import type { CreateOrAttachResult } from './types' export function daemonSpawnResultIdentity( result: CreateOrAttachResult -): Pick { +): Pick< + PtySpawnResult, + | 'incarnationId' + | 'agentSessionEnsure' + | 'launchAgent' + | 'switchOutcomeUnknown' + | 'switchCleanupWitness' +> { return { + ...(result.switchCleanupWitness ? { switchCleanupWitness: result.switchCleanupWitness } : {}), + ...(result.switchOutcomeUnknown ? { switchOutcomeUnknown: true as const } : {}), ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 04dff1c93d43..09bd40432954 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -1,3 +1,5 @@ +import { createAdmissionStreamClient } from './daemon-terminal-stream-client' +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' import { performance } from 'node:perf_hooks' import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' import type { DaemonClientConnections } from './daemon-client-connections' @@ -9,10 +11,9 @@ import type { PendingPtySpawnPreparation } from './daemon-pty-spawn-preparations' import type { DaemonSessionAttachments } from './daemon-session-attachments' -import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' -import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' +import type { CreateOrAttachResult, TerminalHost } from './terminal-host' import { isTuiAgent } from '../../shared/tui-agent-config' import { isSpawnIntent } from '../../shared/host-spawn-admission' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -21,7 +22,6 @@ import { isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import { type DaemonRequest, TerminalAttachCanceledError } from './types' - type CreateOrAttachRequest = Extract const MAX_SPAWN_ADMISSION_WINDOW_MS = 12_000 @@ -35,7 +35,7 @@ function resolveSpawnAdmissionDeadline(cancelAfterMs: unknown): number { return Date.now() + windowMs } -type DaemonTerminalAdmissionOptions = { +export type DaemonTerminalAdmissionOptions = { host: TerminalHost connections: DaemonClientConnections endpoint: DaemonEndpointLifecycle @@ -51,8 +51,6 @@ type DaemonTerminalAdmissionOptions = { } export class DaemonTerminalAdmission { - private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 - private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 private createOrAttachInFlight = 0 constructor(private readonly options: DaemonTerminalAdmissionOptions) {} @@ -75,10 +73,21 @@ export class DaemonTerminalAdmission { this.options.requestEndpointRetirement() throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) } + if (payload.switchExecution?.phase === 'prepare') { + this.options.host.noteSpawnPreparation(payload.sessionId) + return this.options.preparations.prepareSwitch( + payload.sessionId, + clientId, + request.id, + payload.switchExecution.invocationId + ) + } + this.options.host.noteSpawnPreparation(payload.sessionId) this.createOrAttachInFlight++ let routedSessionId = payload.sessionId let result: CreateOrAttachResult let spawnPreparation: PendingPtySpawnPreparation | null = null + const creation: { current: PtyCreationDisposition } = { current: { kind: 'no-fork' } } try { if ( payload.agentSessionEnsure !== undefined && @@ -87,12 +96,19 @@ export class DaemonTerminalAdmission { ) { throw new Error('agent_session_identity_required') } - spawnPreparation = this.options.preparations.register( - payload.sessionId, - clientId, - request.id, - payload.cancelAfterMs - ) + spawnPreparation = payload.switchExecution + ? this.options.preparations.commitSwitch( + payload.sessionId, + clientId, + request.id, + payload.switchExecution + ) + : this.options.preparations.register( + payload.sessionId, + clientId, + request.id, + payload.cancelAfterMs + ) if (!attachOnly) { await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation) } @@ -105,6 +121,7 @@ export class DaemonTerminalAdmission { : payload.historySeed !== undefined ? [payload.historySeed] : undefined + creation.current = { kind: 'unknown' } result = await this.options.host.createOrAttach({ sessionId: payload.sessionId, cols: payload.cols, @@ -132,16 +149,70 @@ export class DaemonTerminalAdmission { declaredIntent: isSpawnIntent(payload.spawnIntent) ? payload.spawnIntent : null, deadlineAt: resolveSpawnAdmissionDeadline(payload.cancelAfterMs) }, - isCanceled: () => spawnPreparation?.canceled === true, + retainCanceledChild: Boolean(payload.switchExecution), + onCreationDisposition: (disposition) => { + creation.current = disposition + }, + isCanceled: () => + spawnPreparation !== null && this.options.preparations.isCanceled(spawnPreparation), cancelSignal: spawnPreparation.controller.signal, onSessionResolved: (sessionId) => { routedSessionId = sessionId }, - streamClient: this.createStreamClient(clientId, () => routedSessionId) + streamClient: createAdmissionStreamClient(this.options, clientId, () => routedSessionId) }) + if (spawnPreparation.settlement) { + const { attachToken: _attachToken, ...wireResult } = result + spawnPreparation.settlement.result = wireResult + const sessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId + const anchor = spawnPreparation.switchAnchor + if (result.isNew && result.switchCleanupWitness && anchor) { + const token = result.switchCleanupWitness + spawnPreparation.settlement.reconcile = async () => { + const close = this.options.host.beginFencedTermination({ + id: sessionId, + expectedIncarnationId: result.incarnationId, + requireNoAgentOwner: !result.agentSessionEnsure, + requireUnattached: true, + switchCondition: { + token, + remainingMs: Math.max(0, anchor.deadline - performance.now()), + ownedSpawn: true, + ...(result.agentSessionEnsure + ? { claimGeneration: result.agentSessionEnsure.owner.generation } + : {}) + } + }) + if (close.accepted) { + await close.completion + } + } + } + } } finally { + const hostDisposition = creation.current if (spawnPreparation) { - this.options.preparations.finish(payload.sessionId, spawnPreparation) + const preparation = spawnPreparation + const settlement = preparation.settlement + if (settlement) { + if (hostDisposition.kind === 'unknown' && settlement.result) { + settlement.result.switchOutcomeUnknown = true + result!.switchOutcomeUnknown = true + } + settlement.noChild = hostDisposition.kind === 'no-fork' + if (!settlement.result && hostDisposition.kind !== 'no-fork') { + settlement.reconcile = hostDisposition.recover + } + if (hostDisposition.kind === 'physical' || hostDisposition.kind === 'failed') { + this.options.preparations.trackPhysical( + payload.sessionId, + preparation, + hostDisposition.completion, + hostDisposition.kind === 'physical' ? hostDisposition.exitCode : undefined + ) + } + } + this.options.preparations.finish(payload.sessionId, preparation) } this.createOrAttachInFlight-- this.options.reevaluateIdleShutdown() @@ -156,7 +227,16 @@ export class DaemonTerminalAdmission { this.options.host.detach(routedSessionId, result.attachToken) throw new TerminalAttachCanceledError(routedSessionId) } - this.options.attachments.attach(routedSessionId, clientId, result.attachToken) + if ( + payload.switchExecution && + spawnPreparation && + this.options.preparations.isCanceled(spawnPreparation) + ) { + result.switchOutcomeUnknown = true + } + if (!result.switchOutcomeUnknown) { + this.options.attachments.attach(routedSessionId, clientId, result.attachToken) + } this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) { this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { @@ -172,6 +252,8 @@ export class DaemonTerminalAdmission { }) return { isNew: result.isNew, + ...(result.switchCleanupWitness ? { switchCleanupWitness: result.switchCleanupWitness } : {}), + ...(result.switchOutcomeUnknown ? { switchOutcomeUnknown: true as const } : {}), snapshot: result.snapshot, pid: result.pid, shellState: result.shellState, @@ -186,60 +268,4 @@ export class DaemonTerminalAdmission { ...(result.inputClosed ? { inputClosed: true as const } : {}) } } - - private createStreamClient( - clientId: string, - sessionId: () => string - ): CreateOrAttachOptions['streamClient'] { - return { - onData: (data, rawLength = data.length, transformed = false, seq) => { - const routedSessionId = sessionId() - this.options.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS - this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq - }) - }, - onInputClosed: (incarnationId) => { - const routedSessionId = sessionId() - this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'inputClosed', - sessionId: routedSessionId, - payload: { incarnationId } - }) - this.options.streamDataBatcher.flush(clientId) - }, - onExit: (code, incarnationId, cause) => { - const routedSessionId = sessionId() - this.options.log.log('session-exited', { - sessionId: routedSessionId, - code, - cause: cause?.kind - }) - this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'exit', - sessionId: routedSessionId, - payload: { code, incarnationId, ...(cause ? { cause } : {}) } - }) - this.options.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: routedSessionId.slice(-10) - }) - this.options.transientFactRelay.onSessionExit(routedSessionId) - this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) - this.options.attachments.release(routedSessionId) - this.options.reevaluateIdleShutdown() - } - } - } } diff --git a/src/main/daemon/daemon-terminal-stream-client.ts b/src/main/daemon/daemon-terminal-stream-client.ts new file mode 100644 index 000000000000..90b30966d6a0 --- /dev/null +++ b/src/main/daemon/daemon-terminal-stream-client.ts @@ -0,0 +1,62 @@ +import { performance } from 'node:perf_hooks' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { CreateOrAttachOptions } from './terminal-host' +import type { DaemonTerminalAdmissionOptions } from './daemon-terminal-admission' +const INTERACTIVE_OUTPUT_WINDOW_MS = 100 +const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 +export function createAdmissionStreamClient( + options: DaemonTerminalAdmissionOptions, + clientId: string, + sessionId: () => string +): CreateOrAttachOptions['streamClient'] { + return { + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= INTERACTIVE_OUTPUT_WINDOW_MS + options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, + onInputClosed: (incarnationId) => { + const routedSessionId = sessionId() + options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'inputClosed', + sessionId: routedSessionId, + payload: { incarnationId } + }) + options.streamDataBatcher.flush(clientId) + }, + onExit: (code, incarnationId, cause) => { + const routedSessionId = sessionId() + options.log.log('session-exited', { + sessionId: routedSessionId, + code, + cause: cause?.kind + }) + options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'exit', + sessionId: routedSessionId, + payload: { code, incarnationId, ...(cause ? { cause } : {}) } + }) + options.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: routedSessionId.slice(-10) + }) + options.transientFactRelay.onSessionExit(routedSessionId) + options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + options.attachments.release(routedSessionId) + options.reevaluateIdleShutdown() + } + } +} diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts index b5574e20dd37..2e002a0e7f2c 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -1,4 +1,13 @@ +import '../mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnNativeDaemonPty } from './native-pty-spawn' +import { createDaemonPtySubprocessHandle } from './subprocess-handle' +import { TerminalCreationWork } from '../terminal-creation-work' +import { + _resetConptyStartupTrustForTest, + CONPTY_NO_INPUT_READER_NOTICE, + CONPTY_STARTUP_DECISION_LIMIT_MS +} from './conpty-startup-input-reader' const nodePty = vi.hoisted(() => ({ spawn: vi.fn() })) vi.mock('node-pty', () => nodePty) @@ -9,18 +18,9 @@ const ptyJob = vi.hoisted(() => ({ })) vi.mock('../../windows/windows-pty-job', () => ptyJob) -import '../mock-descendant-sweep' import { TerminalHost } from '../terminal-host' import { TerminalSpawnGate } from '../terminal-spawn-gate' -import { TerminalCreationWork } from '../terminal-creation-work' import { IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from '../immediate-kill-reply-budget' -import { createDaemonPtySubprocessHandle } from './subprocess-handle' -import { spawnNativeDaemonPty } from './native-pty-spawn' -import { - _resetConptyStartupTrustForTest, - CONPTY_NO_INPUT_READER_NOTICE, - CONPTY_STARTUP_DECISION_LIMIT_MS -} from './conpty-startup-input-reader' const attempts = ['pwsh.exe', 'powershell.exe', 'cmd.exe'].map((shellPath) => ({ shellPath, @@ -45,7 +45,7 @@ const CONPTY_PRELUDE = '\x1b[1t\x1b[c\x1b[?1004h\x1b[?9001h' /** A pane that writes `chunks` on later ticks, as ConPTY's output socket does. */ function fakePane(pid: number, chunks: string[], exitCode?: number) { const dataListeners = new Set<(data: string) => void>() - const exitListeners = new Set<(event: { exitCode: number }) => void>() + const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>() const pane = { pid, kill: vi.fn(() => { @@ -63,6 +63,12 @@ function fakePane(pid: number, chunks: string[], exitCode?: number) { listener(data) } }, + exitSubscriberCount: () => exitListeners.size, + exit: (exitCode: number, signal?: number) => { + for (const listener of exitListeners) { + listener({ exitCode, ...(signal !== undefined ? { signal } : {}) }) + } + }, emitExit: (exitCode: number) => { for (const listener of exitListeners) { listener({ exitCode }) @@ -72,7 +78,7 @@ function fakePane(pid: number, chunks: string[], exitCode?: number) { dataListeners.add(listener) return { dispose: () => dataListeners.delete(listener) } }, - onExit: (listener: (event: { exitCode: number }) => void) => { + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { exitListeners.add(listener) return { dispose: () => exitListeners.delete(listener) } } @@ -113,6 +119,42 @@ describe('Windows shell fallback over node-pty ConPTY', () => { vi.restoreAllMocks() }) + it.each(['physical', 'exited'] as const)( + 'keeps every %s predecessor when a later native invocation fails', + async (predecessor) => { + const creationWork = new TerminalCreationWork() + const pane = healthyPane(999_999_999) + nodePty.spawn.mockReturnValueOnce(pane) + await spawnNativeDaemonPty({ ...args, creationWork }) + if (predecessor === 'exited') { + pane.emitExit(0) + } + nodePty.spawn.mockImplementation(() => { + throw new Error('later no-export failure') + }) + await expect(spawnNativeDaemonPty({ ...args, creationWork })).rejects.toThrow( + 'later no-export failure' + ) + await creationWork.finish() + const disposition = await creationWork.completion + if (disposition.kind !== 'failed') { + throw new Error('failed aggregate was collapsed') + } + let completed = false + void disposition.completion.then(() => { + completed = true + }) + await Promise.resolve() + if (predecessor === 'physical') { + expect(completed).toBe(false) + } + expect(disposition.recover).toBeTypeOf('function') + pane.emitExit(0) + await disposition.completion + expect((await creationWork.completion).kind).toBe('failed') + } + ) + it('keeps a WSL pane out of a distro the internal allowlist excludes, falling back past it', async () => { vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'orca-test-a') try { @@ -156,7 +198,12 @@ describe('Windows shell fallback over node-pty ConPTY', () => { } return proc }) - const result = await spawnNativeDaemonPty(args) + const creationWork = new TerminalCreationWork() + const result = await spawnNativeDaemonPty({ ...args, creationWork }) + await creationWork.finish() + // Ordinary fallback keeps the actual returned owner and earlier failed physical-unknown fact. + expect(await creationWork.completion).toMatchObject({ kind: 'failed' }) + proc.emitExit(0) expect(nodePty.spawn.mock.calls.map(([file]) => file)).toEqual([ 'pwsh.exe', 'powershell.exe', @@ -172,9 +219,23 @@ describe('Windows shell fallback over node-pty ConPTY', () => { // Measured on CI: such a pane prints its banner and prompt, then ignores every keystroke. const broken = fakePane(990001, ['Microsoft Windows [Version 10.0.20348]\r\n']) const healthy = fakePane(990002, ['\x1b[1t', '\x1b[c\x1b[?1004h', 'Microsoft Windows']) + broken.kill.mockImplementation(() => broken.emitExit(137)) nodePty.spawn.mockReturnValueOnce(broken).mockReturnValueOnce(healthy) - - const result = await spawnNativeDaemonPty(args) + const creationWork = new TerminalCreationWork() + const result = await spawnNativeDaemonPty({ ...args, creationWork }) + await creationWork.finish() + const disposition = await creationWork.completion + if (disposition.kind !== 'physical') { + throw new Error('native attempts lost their owners') + } + let completed = false + void disposition.completion.then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).toBe(false) + healthy.emitExit(0) + await disposition.completion expect(nodePty.spawn.mock.calls.map(([file]) => file)).toEqual(['pwsh.exe', 'pwsh.exe']) expect(ptyJob.terminatePtyJob).toHaveBeenCalledWith(broken) @@ -186,6 +247,26 @@ describe('Windows shell fallback over node-pty ConPTY', () => { expect(result.claimStartupEvents?.().data).toEqual(['\x1b[1t\x1b[?1004h']) }) + it('keeps failed after a retained ConPTY predecessor really exits before the native retry throws', async () => { + const creationWork = new TerminalCreationWork() + const broken = deadPane(999_999_999) + broken.kill.mockImplementation(() => broken.emitExit(137)) + nodePty.spawn.mockReturnValueOnce(broken).mockImplementationOnce(() => { + throw new Error('retry exported no handle') + }) + await expect( + spawnNativeDaemonPty({ ...args, windowsFallbackAttempts: attempts.slice(0, 1), creationWork }) + ).rejects.toThrow('retry exported no handle') + await creationWork.finish() + const disposition = await creationWork.completion + if (disposition.kind !== 'failed') { + throw new Error('failed retry was collapsed into exit') + } + await disposition.completion + expect(nodePty.spawn.mock.calls.map(([file]) => file)).toEqual(['pwsh.exe', 'pwsh.exe']) + expect(broken.kill).toHaveBeenCalledOnce() + expect(broken.write).not.toHaveBeenCalled() + }) it.each(['physical-exit', 'deadline', 'observation-retry'] as const)( 'ordinary create keeps the startup predecessor owned through %s and host disposal', async (outcome) => { @@ -374,7 +455,108 @@ describe('Windows shell fallback over node-pty ConPTY', () => { const result = await spawnNativeDaemonPty(args) expect(nodePty.spawn).toHaveBeenCalledOnce() - expect(result.claimStartupEvents?.().exit).toEqual({ exitCode: 3 }) + const exits = vi.fn() + result.physicalOwner?.onExit(exits) + expect(exits).toHaveBeenCalledExactlyOnceWith({ exitCode: 3 }) + expect(result.claimStartupEvents?.().data).toEqual([]) + }) + + it('commits one receipt before reentrant, disposed and throwing consumers receive it', async () => { + const pane = healthyPane(990001) + const subscribe = pane.onExit + let nativeExit: Parameters[0] | undefined + pane.onExit = (listener) => { + nativeExit = listener + return subscribe(listener) + } + nodePty.spawn.mockReturnValue(pane) + const result = await spawnNativeDaemonPty(args) + result.claimStartupEvents?.() + const owner = result.physicalOwner + if (!owner) { + throw new Error('selected physical owner missing') + } + const late = vi.fn() + const removed = vi.fn() + const surviving = vi.fn() + const first = vi.fn() + let remove = (): void => {} + const failure = new Error('exit consumer failed') + owner.onExit((event) => { + first(event) + expect(owner.hasExited()).toBe(true) + expect(owner.exitCode()).toBe(7) + owner.onExit(late) + remove() + throw failure + }) + const subscription = owner.onExit(removed) + remove = () => subscription.dispose() + owner.onExit(surviving) + expect(() => pane.exit(7, 15)).toThrow(failure) + await owner.completion + expect(first).toHaveBeenCalledExactlyOnceWith({ exitCode: 7, signal: 15 }) + expect(late).toHaveBeenCalledExactlyOnceWith({ exitCode: 7, signal: 15 }) + expect(surviving).toHaveBeenCalledExactlyOnceWith({ exitCode: 7, signal: 15 }) + expect(removed).not.toHaveBeenCalled() + expect(pane.exitSubscriberCount()).toBe(0) + nativeExit?.({ exitCode: 99 }) + const replay = vi.fn() + owner.onExit(replay).dispose() + expect(replay).toHaveBeenCalledExactlyOnceWith({ exitCode: 7, signal: 15 }) + expect(first).toHaveBeenCalledOnce() + expect(surviving).toHaveBeenCalledOnce() + expect(pane.kill).not.toHaveBeenCalled() + }) + + it('retires native observation even when exit arrives during subscription installation', async () => { + const pane = fakePane(990001, []) + const retired = vi.fn(() => true) + pane.onExit = (listener) => { + listener({ exitCode: 3, signal: 15 }) + return { dispose: retired } + } + nodePty.spawn.mockReturnValue(pane) + const result = await spawnNativeDaemonPty(args) + const exits = vi.fn() + result.physicalOwner?.onExit(exits) + expect(exits).toHaveBeenCalledExactlyOnceWith({ exitCode: 3, signal: 15 }) + expect(retired).toHaveBeenCalledOnce() + expect(result.claimStartupEvents?.().data).toEqual([]) + expect(nodePty.spawn).toHaveBeenCalledOnce() + }) + + it('does not deliver a queued exit into a handle disposed by an earlier consumer', async () => { + const pane = healthyPane(990001) + nodePty.spawn.mockReturnValue(pane) + const result = await spawnNativeDaemonPty(args) + const owner = result.physicalOwner + if (!owner) { + throw new Error('selected physical owner missing') + } + const retire = owner.onExit(() => handle.dispose()) + const handle = createDaemonPtySubprocessHandle({ + process: result.process, + physicalOwner: owner, + shellPath: result.shellPath, + spawnCwd: result.spawnCwd, + env: {}, + reportsChildExitStatus: result.reportsChildExitStatus, + startupCommandDeliveredInShellArgs: false, + startupAgentRecognition: null, + sessionId: 'disposed-during-exit', + startupEvents: result.claimStartupEvents?.() + }) + const exits = vi.fn() + handle.onExit(exits) + pane.emitExit(0) + await owner.completion + expect(exits).not.toHaveBeenCalled() + expect(pane.exitSubscriberCount()).toBe(0) + handle.write('after disposal') + expect(pane.write).toHaveBeenCalledExactlyOnceWith('\x1b[?61;4c') + retire.dispose() + handle.dispose() }) it('kills the pane tree when the spawn is canceled while startup is awaited', async () => { diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts index 97d9b394dd13..5872cb9ef664 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -1,3 +1,4 @@ +import { resolveSuspendedAgentProcessFromPs } from '../../providers/suspended-agent-process' import type { IPty } from 'node-pty' import { isShellProcess } from '../../../shared/shell-process-detection' import { @@ -70,7 +71,9 @@ export function resolveSpawnFileForegroundFromRows( '' ) const selected = selectForegroundProcessCandidate(candidates, tree) + const suspendedAgentProcess = resolveSuspendedAgentProcessFromPs(rows, rootPid) return { + ...(suspendedAgentProcess ? { suspendedAgentProcess } : {}), available: name.length > 0, processName: selected ? resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, tree) diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index a1505b435f26..d45d3e126f4f 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -11,7 +11,12 @@ import type { SubprocessHandle } from '../session-subprocess-handle' import { createPtyForegroundProcessTracker } from './foreground-process-tracker' import { PtyPreListenerEvents } from './pre-listener-events' import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process' -import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes' +import { + inspectSpawnFileWindowsChildProcesses, + inspectSpawnFileChildProcessesFromRows +} from './spawn-file-child-processes' +import { getStrictProcessTableSnapshotWithAge } from '../../../shared/process-table-snapshot-reader' +import { getSpawnedShellName } from '../../providers/local-pty-launch-helpers' import type { ConptyStartupEvents } from './conpty-startup-input-reader' export { createDaemonPtyPhysicalOwner } from './daemon-pty-physical-owner' @@ -129,12 +134,20 @@ export function createDaemonPtySubprocessHandle(args: { processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc), // Only the login(1) wrapper hides the shell's exit status, and it forks the shell as its child. shellIsRootChild: !reportsChildExitStatus, - ...(process.platform === 'win32' - ? { - inspectChildProcesses: (fresh?: boolean, ignoreDetached?: boolean) => - inspectSpawnFileWindowsChildProcesses(proc, shellPath, fresh, ignoreDetached) - } - : {}), + inspectChildProcesses: async (fresh?: boolean, ignoreDetached?: boolean) => { + if (dead || masterRetired) { + return 'unverifiable' + } + const verdict = + process.platform === 'win32' + ? await inspectSpawnFileWindowsChildProcesses(proc, shellPath, fresh, ignoreDetached) + : inspectSpawnFileChildProcessesFromRows( + (await getStrictProcessTableSnapshotWithAge()).rows, + proc.pid, + getSpawnedShellName(shellPath) + ) + return dead || masterRetired ? 'unverifiable' : verdict + }, shellPath: args.shellPath, shellCwd: args.spawnCwd, shellPathEnv: args.env.PATH, diff --git a/src/main/daemon/session-input-path.ts b/src/main/daemon/session-input-path.ts index 754f8622b391..ed92b7ef86d8 100644 --- a/src/main/daemon/session-input-path.ts +++ b/src/main/daemon/session-input-path.ts @@ -44,7 +44,10 @@ export function attachSessionInputViewer( } export function isSessionInputClosed(session: Session): boolean { - return session.isAlive && inputPaths.get(session)?.subprocess.isInputClosed?.() === true + return ( + session.isAlive && + (session.isSwitchClosing || inputPaths.get(session)?.subprocess.isInputClosed?.() === true) + ) } /** Dropping bytes a retired master cannot take would let every caller report delivery. */ diff --git a/src/main/daemon/session-process-inspection.ts b/src/main/daemon/session-process-inspection.ts new file mode 100644 index 000000000000..84a434c06895 --- /dev/null +++ b/src/main/daemon/session-process-inspection.ts @@ -0,0 +1,63 @@ +import type { SessionTerminationController } from './session-termination-controller' +import type { SessionOutputPlane } from './session-output-plane' +import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { SubprocessHandle, ChildProcessVerdictRead } from './session-subprocess-handle' +import { SessionSwitchUse } from './session-switch-use' +export abstract class SessionProcessInspection extends SessionSwitchUse { + protected abstract subprocess: SubprocessHandle + protected abstract readonly output: SessionOutputPlane + protected abstract readonly recoveryBarrier: TerminalShellRecoveryBarrier + inspectChildProcesses(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead { + return this.subprocess.inspectChildProcesses?.(fresh, ignoreDetached) ?? 'unverifiable' + } + + getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null { + return this.subprocess.getForegroundProcess(options) + } + + async confirmForegroundProcess(context?: { decision?: boolean }): Promise { + return ( + this.subprocess.confirmForegroundProcess?.(context) ?? this.subprocess.getForegroundProcess() + ) + } + + confirmShellForeground(): Promise { + return this.recoveryBarrier.confirmOwnerSettled() + } + + async settleShellOwnershipConfirmation(): Promise { + await this.recoveryBarrier.awaitProofSettled() + // Snapshot only after the drained prompt is parsed into the model. + await this.output.flushParsedWrites() + } + + getPartialEscapeTailAnsi(): string { + return this.output.getPartialEscapeTailAnsi() + } + + getAppliedSize(): { cols: number; rows: number } | null { + return this.output.getAppliedSize() + } + + getCwd(): string | null { + return this.output.getCwd() + } + + protected abstract readonly termination: SessionTerminationController + + signalTerminationRoot(): void { + this.termination.signalTerminationRoot() + } + + scheduleForceDisposeFallback(): void { + this.termination.scheduleForceDisposeFallback() + } + + async forceKillAndWaitForExit(timeoutMs?: number): Promise { + await this.termination.forceKillAndWaitForExit(timeoutMs) + } + + signal(sig: string): void { + this.termination.signal(sig) + } +} diff --git a/src/main/daemon/session-shell-ready-barrier.ts b/src/main/daemon/session-shell-ready-barrier.ts index 1858b6ce1b41..cc45fd8e7688 100644 --- a/src/main/daemon/session-shell-ready-barrier.ts +++ b/src/main/daemon/session-shell-ready-barrier.ts @@ -26,6 +26,7 @@ export type SessionShellReadyBarrierDeps = { installDeviceAttributesFilter(): void releaseDeviceAttributesFilter(): void acceptStartupIngress(data: string): void + beforeCommandWrite?(): boolean /** Use an injected file-log sink because detached daemon stdio is ignored. */ reportReadinessEvent?(event: string, details: Record): void } @@ -67,6 +68,11 @@ export class SessionShellReadyBarrier { return this._state } + /** Keep queuing through the post-marker flush window to preserve input order. */ + get hasPendingWrites(): boolean { + return this.preReadyStdinQueue.length > 0 + } + /** Keep startup commands queued through the post-marker flush window. */ get isGatingWrites(): boolean { return this._state === 'pending' || this.postReadyFlushGate.isPending @@ -182,7 +188,9 @@ export class SessionShellReadyBarrier { const queued = this.preReadyStdinQueue this.preReadyStdinQueue = [] for (const data of queued) { - this.deps.subprocess.write(data) + if (this.deps.beforeCommandWrite?.() !== false) { + this.deps.subprocess.write(data) + } } } } diff --git a/src/main/daemon/session-subprocess.test-fixture.ts b/src/main/daemon/session-subprocess.test-fixture.ts new file mode 100644 index 000000000000..2fcfe027efdc --- /dev/null +++ b/src/main/daemon/session-subprocess.test-fixture.ts @@ -0,0 +1,78 @@ +import { vi } from 'vitest' + +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' + +export function createMockSubprocess() { + const written: string[] = [] + const signals: string[] = [] + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let killed = false + let clearCalls = 0 + let pid = 12345 + let pauseCalls = 0 + let resumeCalls = 0 + + return { + written, + signals, + get killed() { + return killed + }, + get pid() { + return pid + }, + get pauseCalls() { + return pauseCalls + }, + get resumeCalls() { + return resumeCalls + }, + foregroundProcess: null as string | null, + getForegroundProcess(): string | null { + return this.foregroundProcess + }, + confirmShellForeground: vi.fn(async () => true), + inspectChildProcesses: vi.fn(async (): Promise => 'no-children'), + write(data: string) { + written.push(data) + }, + resize(_cols: number, _rows: number) {}, + pause() { + pauseCalls++ + }, + resume() { + resumeCalls++ + }, + get clearCalls() { + return clearCalls + }, + clear() { + clearCalls++ + }, + kill() { + killed = true + setTimeout(() => onExit?.(0), 5) + }, + terminateOwnedTree: () => 'terminated' as const, + forceKill() { + killed = true + }, + signal(sig: string) { + signals.push(sig) + }, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(cb: (code: number) => void) { + onExit = cb + }, + dispose() {}, + simulateData(data: string) { + onData?.(data) + }, + simulateExit(code: number) { + onExit?.(code) + } + } +} diff --git a/src/main/daemon/session-switch-use.ts b/src/main/daemon/session-switch-use.ts new file mode 100644 index 000000000000..50899e8f4b19 --- /dev/null +++ b/src/main/daemon/session-switch-use.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' + +export abstract class SessionSwitchUse { + abstract get isTerminating(): boolean + abstract beginTermination(): boolean + private useRevision = 0 + + protected switchClosing = false + + private switchWitness?: { token: string; revision: number; anchor: number } + + get isSwitchClosing(): boolean { + return this.switchClosing + } + + noteUse(): void { + this.useRevision++ + } + + captureSwitchWitness(): string { + const token = randomUUID() + this.switchWitness = { token, revision: this.useRevision, anchor: performance.now() } + return token + } + + switchCloseCondition(condition: { + token: string + remainingMs: number + ownedSpawn?: true + }): () => boolean { + const witness = this.switchWitness + const deadline = (witness?.anchor ?? -Infinity) + Math.max(0, condition.remainingMs) + return () => + Boolean( + condition.ownedSpawn && + witness && + this.switchWitness === witness && + witness.token === condition.token && + witness.revision === this.useRevision && + performance.now() < deadline && + !this.isTerminating + ) + } + + commitSwitchClose(check: () => boolean): boolean { + if (!check() || !this.beginTermination()) { + return false + } + this.switchClosing = true + return true + } +} diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ce8f6c633663..33fd5f6f2bc6 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -11,86 +11,13 @@ import { _resetPtyOwnerHostColorsForTest, setPtyOwnerHostColors } from '../../shared/pty-owner-color-query-colors' +import { createMockSubprocess } from './session-subprocess.test-fixture' const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: killWithDescendantSweepMock })) -function createMockSubprocess() { - const written: string[] = [] - const signals: string[] = [] - let onData: ((data: string) => void) | null = null - let onExit: ((code: number) => void) | null = null - let killed = false - let clearCalls = 0 - let pid = 12345 - let pauseCalls = 0 - let resumeCalls = 0 - - return { - written, - signals, - get killed() { - return killed - }, - get pid() { - return pid - }, - get pauseCalls() { - return pauseCalls - }, - get resumeCalls() { - return resumeCalls - }, - foregroundProcess: null as string | null, - getForegroundProcess(): string | null { - return this.foregroundProcess - }, - confirmShellForeground: vi.fn(async () => true), - write(data: string) { - written.push(data) - }, - resize(_cols: number, _rows: number) {}, - pause() { - pauseCalls++ - }, - resume() { - resumeCalls++ - }, - get clearCalls() { - return clearCalls - }, - clear() { - clearCalls++ - }, - kill() { - killed = true - setTimeout(() => onExit?.(0), 5) - }, - terminateOwnedTree: () => 'terminated' as const, - forceKill() { - killed = true - }, - signal(sig: string) { - signals.push(sig) - }, - onData(cb: (data: string) => void) { - onData = cb - }, - onExit(cb: (code: number) => void) { - onExit = cb - }, - dispose() {}, - simulateData(data: string) { - onData?.(data) - }, - simulateExit(code: number) { - onExit?.(code) - } - } -} - type MockSubprocess = ReturnType describe('Session', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index fbd8e46e3596..ad70733f2da6 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -5,13 +5,12 @@ import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController } from './session-termination-controller' -import type { ChildProcessVerdictRead, SubprocessHandle } from './session-subprocess-handle' +import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' - import type { SessionState, ShellReadyState, @@ -19,8 +18,8 @@ import type { TerminalSnapshot } from './types' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' - -export class Session { +import { SessionProcessInspection } from './session-process-inspection' +export class Session extends SessionProcessInspection { readonly sessionId: string readonly incarnationId = randomUUID() readonly terminalHandle: string | null @@ -31,16 +30,18 @@ export class Session { private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false - private subprocess: SubprocessHandle + + protected subprocess: SubprocessHandle private readonly onSessionExit?: (code: number) => void - private readonly output: SessionOutputPlane + protected readonly output: SessionOutputPlane private readonly producerPause: SessionProducerPause - private readonly shellReady: SessionShellReadyBarrier - private readonly termination: SessionTerminationController + protected readonly shellReady: SessionShellReadyBarrier + protected readonly termination: SessionTerminationController readonly startupIngress: PtyStartupIngress - private readonly recoveryBarrier: TerminalShellRecoveryBarrier + protected readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { + super() this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null @@ -70,13 +71,26 @@ export class Session { shellReadyTimeoutMs: opts.shellReadyTimeoutMs, installDeviceAttributesFilter: () => this.output.installDeviceAttributesFilter(), releaseDeviceAttributesFilter: () => this.output.releaseDeviceAttributesFilter(), + beforeCommandWrite: () => { + if (this.switchClosing) { + return false + } + this.noteUse() + return true + }, acceptStartupIngress: (data) => this.startupIngress.accept(data) }) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), - write: (data) => this.subprocess.write(data), + write: (data) => { + if (this.switchClosing) { + return + } + this.noteUse() + this.subprocess.write(data) + }, onEmission: (emission) => this.recoveryBarrier.accept(emission) }) try { @@ -142,15 +156,23 @@ export class Session { } write(data: string): void { + if (this._state === 'exited' || this._disposed || this.switchClosing) { + return + } + this.noteUse() // Human input must reach rc-file readers while automatic startup commands wait for readiness. - if (this.isAlive && !this._disposed && !this.startupIngress.answerLiveQueryReply(data)) { + if (!this.startupIngress.answerLiveQueryReply(data)) { this.subprocess.write(data) } } /** Orca's own startup command: held by the shell-ready barrier until the shell can read it. */ writeStartupCommand(data: string): void { - if (this._state !== 'exited' && !this._disposed && !this.shellReady.tryEnqueue(data)) { + if (this._state === 'exited' || this._disposed || this.switchClosing) { + return + } + this.noteUse() + if (!this.shellReady.tryEnqueue(data)) { this.subprocess.write(data) } } @@ -176,28 +198,19 @@ export class Session { } /** Signal the root only after its descendant snapshot has completed. */ - signalTerminationRoot(): void { - this.termination.signalTerminationRoot() - } /** Arm escalation after a coordinator begins snapshot-first teardown. */ - scheduleForceDisposeFallback(): void { - this.termination.scheduleForceDisposeFallback() - } - - async forceKillAndWaitForExit(timeoutMs?: number): Promise { - await this.termination.forceKillAndWaitForExit(timeoutMs) - } - - signal(sig: string): void { - this.termination.signal(sig) - } attachClient(client: Omit): symbol { + if (this.switchClosing) { + throw new Error('Terminal is closing') + } + this.noteUse() return this.output.attachClient(client) } detachClient(token: symbol): void { + this.noteUse() this.output.detachClient(token) // With no viewer left to resume the producer, release its pause eagerly. if (!this.output.hasAttachedClients) { @@ -206,6 +219,7 @@ export class Session { } detachAllClients(): void { + this.noteUse() this.output.clearClients() this.producerPause.release({ resume: true }) } @@ -215,14 +229,6 @@ export class Session { return this.output.getSnapshot(opts) } - getPartialEscapeTailAnsi(): string { - return this.output.getPartialEscapeTailAnsi() - } - - getAppliedSize(): { cols: number; rows: number } | null { - return this.output.getAppliedSize() - } - takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} @@ -235,34 +241,6 @@ export class Session { return this.output.takePendingOutput(includeSnapshot, heldBytes, () => this.getSnapshot()) } - getCwd(): string | null { - return this.output.getCwd() - } - - inspectChildProcesses(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead { - return this.subprocess.inspectChildProcesses?.(fresh, ignoreDetached) ?? 'unverifiable' - } - - getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null { - return this.subprocess.getForegroundProcess(options) - } - - async confirmForegroundProcess(context?: { decision?: boolean }): Promise { - return ( - this.subprocess.confirmForegroundProcess?.(context) ?? this.subprocess.getForegroundProcess() - ) - } - - confirmShellForeground(): Promise { - return this.recoveryBarrier.confirmOwnerSettled() - } - - async settleShellOwnershipConfirmation(): Promise { - await this.recoveryBarrier.awaitProofSettled() - // Snapshot only after the drained prompt is parsed into the model. - await this.output.flushParsedWrites() - } - clearScrollback(): void { this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } diff --git a/src/main/daemon/terminal-creation-work.ts b/src/main/daemon/terminal-creation-work.ts index 8129e8ca1e74..539b960380d9 100644 --- a/src/main/daemon/terminal-creation-work.ts +++ b/src/main/daemon/terminal-creation-work.ts @@ -1,3 +1,4 @@ +import type { Session } from './session' import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' @@ -171,7 +172,7 @@ export function recoverUnpublishedTerminalCreation(disposition: PtyCreationDispo export async function runTerminalCreation( opts: InternalCreateOrAttachOptions, - sessions: ReadonlyMap, + sessions: ReadonlyMap, pendingCreations: Map, assertCreateAllowed: (options: InternalCreateOrAttachOptions) => void, operation: ( @@ -204,6 +205,7 @@ export async function runTerminalCreation( } assertCreateAllowed(opts) + sessions.get(opts.sessionId)?.noteUse() const finishPending = registerPendingTerminalCreation( pendingCreations, opts.sessionId, diff --git a/src/main/daemon/terminal-host-agent-session-claim.ts b/src/main/daemon/terminal-host-agent-session-claim.ts index b514418cda33..1b1a2f000fc0 100644 --- a/src/main/daemon/terminal-host-agent-session-claim.ts +++ b/src/main/daemon/terminal-host-agent-session-claim.ts @@ -14,6 +14,7 @@ export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { creationWork?: TerminalCreationWork onCreationDisposition?: (disposition: PtyCreationDisposition) => void onPhysicalOwner?: (completion: Promise, exitCode: () => number | null) => void + retainCanceledChild?: boolean isCanceled?: () => boolean cancelSignal?: AbortSignal } @@ -21,6 +22,7 @@ export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { export async function createOrAttachClaimedAgentSession(args: { options: InternalCreateOrAttachOptions owners: ClaimedAgentPtyOwnerRegistry + check?: () => void isLive: (owner: AgentSessionOwnerBinding) => boolean createOrAttach: (options: InternalCreateOrAttachOptions) => Promise }): Promise { @@ -47,7 +49,15 @@ export async function createOrAttachClaimedAgentSession(args: { }) return { ptyId: args.options.sessionId } }, - isLive: args.isLive + isLive: args.isLive, + check: args.check, + onDispatchedRevoked: args.options.retainCanceledChild + ? () => { + if (created) { + created.switchOutcomeUnknown = true + } + } + : undefined }) if (ensured.disposition === 'created' && created) { return { ...(created as CreateOrAttachResult), agentSessionEnsure: ensured } diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index 1cf8ca1537cc..9f631b20a7b6 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -178,6 +178,7 @@ describe('concurrent createOrAttach across the async spawn', () => { }) const creation = host.createOrAttach(createOptions('shutdown-race')) + const canceled = expect(creation).rejects.toThrow('Attach canceled') const disposal = host.dispose() let disposed = false void disposal.then(() => { @@ -188,7 +189,7 @@ describe('concurrent createOrAttach across the async spawn', () => { expect(disposed).toBe(false) releaseSpawn() - await creation + await canceled await disposal expect(subprocess.forceKill).toHaveBeenCalledOnce() diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 4a29ec3350c3..5b8da6a439bd 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -45,6 +45,9 @@ export type CreateOrAttachOptions = { } export type CreateOrAttachResult = { + /** A dispatched child remains owned after its initiating handoff lost authority. */ + switchOutcomeUnknown?: true + switchCleanupWitness?: string isNew: boolean snapshot: TerminalSnapshot | null pid: number | null diff --git a/src/main/daemon/terminal-host-fenced-termination.ts b/src/main/daemon/terminal-host-fenced-termination.ts index 97f93c7ee257..0f96885b409b 100644 --- a/src/main/daemon/terminal-host-fenced-termination.ts +++ b/src/main/daemon/terminal-host-fenced-termination.ts @@ -15,7 +15,10 @@ export function beginTerminalHostFencedTermination( sessions: ReadonlyMap pendingCreations: ReadonlyMap owners: ClaimedAgentPtyOwnerRegistry - kill: (id: string, options: { immediate: true }) => Promise + kill: ( + id: string, + options: { immediate: true; switchCheck?: () => boolean; ownedSpawn?: true } + ) => Promise } ): FencedTermination { const session = deps.sessions.get(request.id) @@ -36,10 +39,33 @@ export function beginTerminalHostFencedTermination( if (request.requireUnattached && session.hasAttachedClients) { return { accepted: false, result: { outcome: 'refused', reason: 'attached' } } } + if (request.switchCondition && !request.switchCondition.ownedSpawn) { + return { accepted: false, result: { outcome: 'refused', reason: 'unsupported' } } + } + const physicalCheck = request.switchCondition + ? session.switchCloseCondition(request.switchCondition) + : undefined + const switchCheck = physicalCheck + ? () => + deps.sessions.get(request.id) === session && + !deps.pendingCreations.has(request.id) && + (!request.requireNoAgentOwner || deps.owners.listForPty(request.id).length === 0) && + (!request.switchCondition?.ownedSpawn || + deps.owners + .listForPty(request.id) + .every((owner) => owner.generation === request.switchCondition?.claimGeneration)) && + physicalCheck() + : undefined + if (switchCheck && !switchCheck()) { + return { accepted: false, result: { outcome: 'refused', reason: 'unverifiable' } } + } try { // kill reserves the exact Session synchronously, before its first OS probe. const completion = deps - .kill(request.id, { immediate: true }) + .kill(request.id, { + immediate: true, + ...(switchCheck ? { switchCheck, ownedSpawn: request.switchCondition?.ownedSpawn } : {}) + }) .then((): PtyFencedTerminationOutcome => session.isAlive ? { outcome: 'refused', reason: 'unverifiable' } : { outcome: 'ended' } ) diff --git a/src/main/daemon/terminal-host-inspection.ts b/src/main/daemon/terminal-host-inspection.ts new file mode 100644 index 000000000000..13828f6628f2 --- /dev/null +++ b/src/main/daemon/terminal-host-inspection.ts @@ -0,0 +1,119 @@ +import { hasLiveTerminalHostSessions } from './terminal-host-session-listing' +import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import { + confirmTerminalHostForegroundProcess, + confirmTerminalHostShellForeground, + getSettledTerminalHostSnapshot, + getTerminalHostAppliedSize, + getTerminalHostPartialEscapeTail, + getTerminalHostSnapshot, + takeTerminalHostPendingOutput +} from './terminal-host-session-inspection-operations' +import type { Session } from './session' +import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' +import { getAliveTerminalHostSession } from './terminal-host-session-access' +import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' +import { + inspectTerminalHostSession, + type TerminalHostProcessInspection +} from './terminal-host-process-inspection' +export abstract class TerminalHostInspection { + protected abstract sessions: Map + protected abstract readonly retiredIncarnations: Map< + string, + { incarnationId: string; code: number; expiresAt: number } + > + protected abstract readonly authorityGeneration: string + protected abstract observationEpoch: number + async getCwd(sessionId: string): Promise { + return await resolveTerminalHostSessionCwd( + getAliveTerminalHostSession(this.sessions, sessionId) + ) + } + + // A vanished pane has no agent icon to display. + getForegroundProcess(sessionId: string): string | null { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + return session.getForegroundProcess() + } + + inspectProcess( + sessionId: string, + options?: PtyProcessInspectionOptions + ): Promise { + return inspectTerminalHostSession( + this.sessions, + this.retiredIncarnations, + sessionId, + options, + this.authorityGeneration, + () => ++this.observationEpoch + ) + } + + async confirmForegroundProcess(sessionId: string): Promise { + return confirmTerminalHostForegroundProcess(this.sessions.get(sessionId)) + } + + async confirmShellForeground(sessionId: string): Promise { + return confirmTerminalHostShellForeground(this.sessions.get(sessionId), () => + this.sessions.get(sessionId) + ) + } + + signal(sessionId: string, sig: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).signal(sig) + } + + detach(sessionId: string, token: symbol): void { + this.detachClients([{ sessionId, token }]) + } + + detachClients(attachments: readonly { sessionId: string; token: symbol }[]): void { + for (const { sessionId, token } of attachments) { + this.sessions.get(sessionId)?.detachClient(token) + } + } + + clearScrollback(sessionId: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() + } + + resetInputModes(sessionId: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes() + } + + getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { + return getTerminalHostSnapshot(this.sessions.get(sessionId), opts) + } + + async getSettledSnapshot( + sessionId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + return getSettledTerminalHostSnapshot(this.sessions.get(sessionId), opts) + } + + getPartialEscapeTailAnsi(sessionId: string): string { + return getTerminalHostPartialEscapeTail(this.sessions.get(sessionId)) + } + + getAppliedSize(sessionId: string): { cols: number; rows: number } | null { + return getTerminalHostAppliedSize(this.sessions.get(sessionId)) + } + + takePendingOutput( + sessionId: string, + includeSnapshot: boolean, + opts: { teardownSnapshot?: boolean } = {} + ): TakePendingOutputResult | null { + return takeTerminalHostPendingOutput(this.sessions.get(sessionId), includeSnapshot, opts) + } + + hasLiveSessions(): boolean { + return hasLiveTerminalHostSessions(this.sessions) + } +} diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index 9aa786c6afd0..2ef76d914635 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -20,6 +20,8 @@ import { SessionNotFoundError } from './types' import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' export type TerminalHostProcessInspection = { + switchWitness?: string + foregroundProcess: string | null hasChildProcesses: boolean childProcessEvidence?: PtyChildProcessVerdict @@ -39,6 +41,7 @@ export async function inspectTerminalHostProcess(args: { /** The caller is a self-correcting poll that only reads the process name, never evidence. */ steadyState?: boolean ignoreDetached?: boolean + strictChildren?: boolean retiredIncarnation?: RetiredIncarnation authorityGeneration: string nextObservationEpoch: () => number @@ -104,7 +107,7 @@ export async function inspectTerminalHostProcess(args: { } else { try { const snapshot = await getStrictProcessTableSnapshotWithAge() - if (session.processNameIsSpawnFile) { + if (session.processNameIsSpawnFile || args.strictChildren) { const observed = resolveSpawnFileForegroundFromRows(snapshot.rows, session.pid) foregroundProcess = observed.available ? observed.processName : foregroundProcess childProcessEvidence = inspectSpawnFileChildProcessesFromRows( @@ -211,13 +214,16 @@ export function inspectTerminalHostSession( sessionId, options?.expectedIncarnationId ) + const witness = + options?.captureSwitchWitness && session?.isAlive ? session.captureSwitchWitness() : undefined return inspectTerminalHostProcess({ sessionId, currentSession: () => sessions.get(sessionId), session: session?.isAlive ? session : null, ...options, + strictChildren: Boolean(options?.captureSwitchWitness), retiredIncarnation: retired.get(sessionId), authorityGeneration, nextObservationEpoch - }) + }).then((result) => ({ ...result, ...(witness ? { switchWitness: witness } : {}) })) } diff --git a/src/main/daemon/terminal-host-session-publish.ts b/src/main/daemon/terminal-host-session-publish.ts index 0ad776c08a4c..f07e63d466cb 100644 --- a/src/main/daemon/terminal-host-session-publish.ts +++ b/src/main/daemon/terminal-host-session-publish.ts @@ -136,7 +136,13 @@ export async function spawnAndPublishSession( throw error } - if (opts.isCanceled?.()) { + let revoked = false + try { + deps.assertCreateAllowed() + } catch { + revoked = true + } + if (revoked) { // Retain cleanup ownership if the native child refuses to exit. deps.sessions.set(opts.sessionId, session) // Retain the live-cap charge until physical exit. @@ -148,7 +154,21 @@ export async function spawnAndPublishSession( deps.onDeadSessionRemoved(opts.sessionId) deps.spawnGate?.onSessionExit(opts.sessionId) } - throw new TerminalAttachCanceledError(opts.sessionId) + if (!opts.retainCanceledChild || !session.isAlive) { + throw new TerminalAttachCanceledError(opts.sessionId) + } + deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, true) + return { + isNew: true, + switchOutcomeUnknown: true, + switchCleanupWitness: session.captureSwitchWitness(), + snapshot: session.getSnapshot(), + pid: session.pid, + shellState: session.shellState, + incarnationId: session.incarnationId, + ...getDaemonSessionResultMetadata(session), + attachToken: Symbol('retained-switch-child') + } } deps.sessions.set(opts.sessionId, session) @@ -200,6 +220,7 @@ export async function spawnAndPublishSession( return { isNew: true, + ...(opts.retainCanceledChild ? { switchCleanupWitness: session.captureSwitchWitness() } : {}), snapshot: null, pid: subprocess.pid, shellState: session.shellState, diff --git a/src/main/daemon/terminal-host-spawn-input-retention.test.ts b/src/main/daemon/terminal-host-spawn-input-retention.test.ts index 4738380c773c..4b499dd25436 100644 --- a/src/main/daemon/terminal-host-spawn-input-retention.test.ts +++ b/src/main/daemon/terminal-host-spawn-input-retention.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SubprocessHandle } from './session-subprocess-handle' import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import { TerminalHost } from './terminal-host' +import { TerminalSpawnGate } from './terminal-spawn-gate' +import type { PtyCreationDisposition } from '../../shared/claimed-agent-pty-owner' import './mock-descendant-sweep' @@ -45,12 +47,17 @@ function subprocess() { const streamClient = { onData() {}, onExit() {} } -function startWithInputs(host: TerminalHost, sessionId: string) { +function startWithInputs( + host: TerminalHost, + sessionId: string, + onCreationDisposition?: InternalCreateOrAttachOptions['onCreationDisposition'] +) { const controller = new AbortController() const env = { RETENTION_FIXTURE: 'x'.repeat(1024) } const historySeedChunks = ['retention-seed\r\n'] const options: InternalCreateOrAttachOptions = { sessionId, + ...(onCreationDisposition ? { onCreationDisposition } : {}), cols: 80, rows: 24, env, @@ -208,3 +215,80 @@ describe('TerminalHost completed spawn inputs', () => { } }) }) + +it('collects completed inputs while unpublished physical work still owns recovery, capacity and same-ID exclusion', async () => { + const handle = { + ...subprocess(), + forceKill: vi.fn(() => {}), + onInputClosed() { + throw new Error('input-observer-install-failed') + } + } + let spawns = 0 + const spawn = async () => (++spawns === 1 ? handle : subprocess()) + const spawnGate = new TerminalSpawnGate({ log: () => {} }) + const host = new TerminalHost({ spawnSubprocess: spawn, spawnGate }) + const retained: { disposition?: PtyCreationDisposition } = {} + const refs = await observeUnpublishedFailure(host, retained) + try { + expect(retained.disposition?.kind).toBe('physical') + expect(host.listSessions()).toEqual([]) + expect(spawnGate.snapshot().terminals).toBe(1) + await collect() + expect(refs.map((ref) => ref.deref() === undefined)).toEqual(Array(4).fill(true)) + const canceled = new AbortController() + const blocked = host.createOrAttach({ + sessionId: 'unpublished-retained', + cols: 80, + rows: 24, + streamClient, + cancelSignal: canceled.signal, + isCanceled: () => canceled.signal.aborted + }) + const rejected = expect(blocked).rejects.toThrow('canceled') + canceled.abort() + await rejected + expect(spawns).toBe(1) + expect(spawnGate.snapshot().terminals).toBe(1) + const recovered = + retained.disposition?.kind !== 'no-fork' ? retained.disposition?.recover?.() : undefined + handle.emitExit(7) + await recovered + if (retained.disposition?.kind !== 'physical') { + throw new Error('missing physical owner') + } + await retained.disposition.completion + expect(retained.disposition.exitCode()).toBe(7) + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnGate.snapshot().terminals).toBe(0) + expect( + ( + await host.createOrAttach({ + sessionId: 'unpublished-retained', + cols: 80, + rows: 24, + streamClient + }) + ).isNew + ).toBe(true) + expect(spawns).toBe(2) + } finally { + handle.emitExit(7) + await host.dispose() + } +}) + +async function observeUnpublishedFailure( + host: TerminalHost, + retained: { disposition?: PtyCreationDisposition } +): Promise[]> { + const created = startWithInputs(host, 'unpublished-retained', (disposition) => { + retained.disposition = disposition + }) + const failure = await created.creation.then( + () => 'unexpected-success', + (error: unknown) => (error instanceof Error ? error.message : 'unexpected-error') + ) + expect(failure).toBe('input-observer-install-failed') + return created.refs +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 1243ff32de96..506cc15d8493 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -3,8 +3,7 @@ import { runTerminalCreation, type PendingTerminalCreation } from './terminal-cr import { beginTerminalHostFencedTermination } from './terminal-host-fenced-termination' import type { PtyFencedTerminationRequest } from '../../shared/pty-fenced-termination' import type { Session } from './session' -import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' -import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' +import { SessionNotFoundError, type SessionInfo } from './types' import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import { recordTerminalHostSessionExit } from './terminal-host-session-exit' @@ -16,35 +15,20 @@ import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-own import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim' import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations' -import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' import { TerminalHostTombstones } from './terminal-host-tombstones' -import { - listLiveTerminalHostSessions, - hasLiveTerminalHostSessions -} from './terminal-host-session-listing' +import { listLiveTerminalHostSessions } from './terminal-host-session-listing' import { createOrAttachTerminalSession } from './terminal-host-session-create' import { TerminalAttachCanceledError } from './daemon-errors' import { randomUUID } from 'node:crypto' -import { inspectTerminalHostSession } from './terminal-host-process-inspection' -import type { TerminalHostProcessInspection } from './terminal-host-process-inspection' -import { - confirmTerminalHostForegroundProcess, - confirmTerminalHostShellForeground, - getSettledTerminalHostSnapshot, - getTerminalHostAppliedSize, - getTerminalHostPartialEscapeTail, - getTerminalHostSnapshot, - takeTerminalHostPendingOutput -} from './terminal-host-session-inspection-operations' - +import { TerminalHostInspection } from './terminal-host-inspection' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type { TerminalHostOptions } from './terminal-host-options' const DEFAULT_MAX_TOMBSTONES = 1000 -export class TerminalHost { - private sessions = new Map() +export class TerminalHost extends TerminalHostInspection { + protected sessions = new Map() // Serializes creates for one id across async spawn validation. private pendingCreations = new Map() private sessionTeardown = new TerminalSessionTeardown(this.sessions) @@ -57,14 +41,15 @@ export class TerminalHost { private disposePromise: Promise | null = null private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations() - private readonly authorityGeneration = randomUUID() - private observationEpoch = 0 - private readonly retiredIncarnations = new Map< + protected readonly authorityGeneration = randomUUID() + protected observationEpoch = 0 + protected readonly retiredIncarnations = new Map< string, { incarnationId: string; code: number; expiresAt: number } >() constructor(opts: TerminalHostOptions) { + super() this.spawnOptions = opts this.onSessionReaped = opts.onSessionReaped this.reportReadinessEvent = opts.reportReadinessEvent @@ -72,6 +57,14 @@ export class TerminalHost { this.killedTombstones = new TerminalHostTombstones(opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES) } + noteSpawnPreparation(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (session?.isTerminating) { + throw new SessionNotFoundError(sessionId) + } + session?.noteUse() + } + createOrAttach(opts: InternalCreateOrAttachOptions): Promise { return runTerminalCreation( opts, @@ -82,6 +75,9 @@ export class TerminalHost { createOrAttachClaimedAgentSession({ options: opts, owners: this.agentSessionOwners, + check: opts.retainCanceledChild + ? () => this.assertCreateOrAttachAllowed(opts) + : undefined, isLive: (owner) => { const session = this.sessions.get(owner.ptyId) const live = this.agentSessionGenerations.isCurrent( @@ -164,17 +160,35 @@ export class TerminalHost { this.sessions.get(sessionId)?.resumeProducer(source) } - kill(sessionId: string, opts: { immediate?: boolean } = {}): Promise { + kill( + sessionId: string, + opts: { immediate?: boolean; switchCheck?: () => boolean; ownedSpawn?: true } = {} + ): Promise { const pending = this.sessionTeardown.get(sessionId) if (pending) { + if (opts.switchCheck) { + return Promise.resolve() + } return Promise.resolve( opts.immediate ? this.sessionTeardown.requestImmediate(sessionId) : pending ) } const session = getAliveTerminalHostSession(this.sessions, sessionId) - const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true) - this.killedTombstones.record(sessionId) - return Promise.resolve(killed) + const killed = this.sessionTeardown.killSession( + sessionId, + session, + opts.immediate === true, + opts.switchCheck, + opts.ownedSpawn + ) + if (!opts.switchCheck) { + this.killedTombstones.record(sessionId) + } + return Promise.resolve(killed).then(() => { + if (opts.switchCheck && (!session.isAlive || session.isTerminating)) { + this.killedTombstones.record(sessionId) + } + }) } beginFencedTermination(request: PtyFencedTerminationRequest) { @@ -197,97 +211,6 @@ export class TerminalHost { this.onSessionReaped?.(sessionId) } - signal(sessionId: string, sig: string): void { - getAliveTerminalHostSession(this.sessions, sessionId).signal(sig) - } - - detach(sessionId: string, token: symbol): void { - this.detachClients([{ sessionId, token }]) - } - - detachClients(attachments: readonly { sessionId: string; token: symbol }[]): void { - for (const { sessionId, token } of attachments) { - this.sessions.get(sessionId)?.detachClient(token) - } - } - - async getCwd(sessionId: string): Promise { - return await resolveTerminalHostSessionCwd( - getAliveTerminalHostSession(this.sessions, sessionId) - ) - } - - // A vanished pane has no agent icon to display. - getForegroundProcess(sessionId: string): string | null { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.getForegroundProcess() - } - - inspectProcess( - sessionId: string, - options?: PtyProcessInspectionOptions - ): Promise { - return inspectTerminalHostSession( - this.sessions, - this.retiredIncarnations, - sessionId, - options, - this.authorityGeneration, - () => ++this.observationEpoch - ) - } - - async confirmForegroundProcess(sessionId: string): Promise { - return confirmTerminalHostForegroundProcess(this.sessions.get(sessionId)) - } - - async confirmShellForeground(sessionId: string): Promise { - return confirmTerminalHostShellForeground(this.sessions.get(sessionId), () => - this.sessions.get(sessionId) - ) - } - - clearScrollback(sessionId: string): void { - getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() - } - - resetInputModes(sessionId: string): void { - getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes() - } - - // Checkpoint reads tolerate sessions that have just exited. - getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { - return getTerminalHostSnapshot(this.sessions.get(sessionId), opts) - } - - async getSettledSnapshot( - sessionId: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - return getSettledTerminalHostSnapshot(this.sessions.get(sessionId), opts) - } - - // Seed scan-authority handoff with the incomplete escape at the current stream position. - getPartialEscapeTailAnsi(sessionId: string): string { - return getTerminalHostPartialEscapeTail(this.sessions.get(sessionId)) - } - - // Let the renderer detect dropped or coerced resizes from the applied dimensions. - getAppliedSize(sessionId: string): { cols: number; rows: number } | null { - return getTerminalHostAppliedSize(this.sessions.get(sessionId)) - } - - takePendingOutput( - sessionId: string, - includeSnapshot: boolean, - opts: { teardownSnapshot?: boolean } = {} - ): TakePendingOutputResult | null { - return takeTerminalHostPendingOutput(this.sessions.get(sessionId), includeSnapshot, opts) - } - isKilled(sessionId: string): boolean { return this.killedTombstones.has(sessionId) } @@ -296,10 +219,6 @@ export class TerminalHost { return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners) } - hasLiveSessions(): boolean { - return hasLiveTerminalHostSessions(this.sessions) - } - /** Last resort when shutdown overruns its deadline: an async group sweep may not have signalled yet. */ killLiveSessionGroupsSync(): void { killLiveSessionGroupsSync(this.sessions.values()) diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index 252cc8f8d316..b2a979c94436 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -68,7 +68,36 @@ export class TerminalSessionTeardown { await Promise.all(this.requestImmediateAll()) } - killSession(sessionId: string, session: Session, immediate: boolean): void | Promise { + killSession( + sessionId: string, + session: Session, + immediate: boolean, + switchCheck?: () => boolean, + ownedSpawn?: true + ): void | Promise { + if (switchCheck) { + return this.track(sessionId, session, true, async () => { + let committed = false + await killWithDescendantSweep(session.pid, () => {}, { + ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive && committed, + admitClose: async () => { + if (!switchCheck()) { + return false + } + if (!ownedSpawn || !switchCheck() || this.sessions.get(sessionId) !== session) { + return false + } + committed = session.commitSwitchClose(switchCheck) + return committed + }, + terminateOwnedTree: () => session.terminateOwnedTree(), + awaitEscalation: true + }) + if (committed && this.sessions.get(sessionId) === session && session.isAlive) { + await session.forceKillAndWaitForExit() + } + }) + } if (!session.launchAgent && !immediate && process.platform === 'win32') { // The PTY job would also end `start /b` and GUI children, which a graceful close leaves running. if (session.beginTermination()) { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index bf348eb6008f..a9feed168caf 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,7 @@ +import type { + CreateOrAttachRequest, + CancelCreateOrAttachRequest +} from './daemon-create-or-attach-contract' import type { AsyncIoHealthRequest, FullDiskAccessStatusRequest, @@ -5,7 +9,6 @@ import type { PtySpawnHealthRequest, SystemResolverHealthRequest } from './daemon-health-requests' -import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { SessionInfo } from './daemon-session-info' import type { ConfirmForegroundProcessRequest, @@ -13,6 +16,16 @@ import type { GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' +import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' +import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' +import type { TerminalSnapshot } from './terminal-snapshot' +import type { PtyFencedTerminationRequest } from '../../shared/pty-fenced-termination' +export { + type CreateOrAttachRequest, + type CreateOrAttachSettlement, + type CancelCreateOrAttachResult, + type CancelCreateOrAttachRequest +} from './daemon-create-or-attach-contract' export type { ConfirmForegroundProcessRequest, @@ -21,17 +34,8 @@ export type { InspectProcessRequest } from './daemon-foreground-process-protocol' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import type { TuiAgent } from '../../shared/tui-agent' -import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import type { - AgentSessionExecutionClaim, - AgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' -import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' -import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' export type { TerminalModes } from './terminal-modes' -import type { TerminalSnapshot } from './terminal-snapshot' + export type { TerminalSnapshot } from './terminal-snapshot' export { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, @@ -57,48 +61,6 @@ export type { TerminalCheckpointFile } from './daemon-checkpoint-file' export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol' -export type CreateOrAttachRequest = { - id: string - type: 'createOrAttach' - payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - command?: string - startupCommandDelivery?: StartupCommandDelivery - launchAgent?: TuiAgent - /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ - attachOnly?: boolean - /** Preserve the selected shell instead of defaulting to the daemon’s platform fallback. */ - shellOverride?: string - terminalShellArgs?: string[] - /** Preferred WSL distro for generic `wsl.exe` launches. */ - terminalWindowsWslDistro?: string | null - /** Preserve the selected PowerShell implementation so daemon and local spawns resolve the same executable. */ - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - shellReadySupported?: boolean - shellReadyTimeoutMs?: number - /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ - cancelAfterMs?: number - startupIngress?: PtyStartupIngressIntent - /** Optional; an old daemon ignores it and an old client omits it (legacy pacing). */ - spawnIntent?: SpawnIntent - agentSessionEnsure?: { - claim: AgentSessionExecutionClaim - surface: AgentSessionSurfaceBinding - } - } -} - -export type CancelCreateOrAttachRequest = { - id: string - type: 'cancelCreateOrAttach' - payload: { sessionId: string; requestId?: string } -} - export type WriteRequest = { id: string type: 'write' @@ -154,6 +116,7 @@ export type KillRequest = { expectedIncarnationId?: string requireNoAgentOwner?: boolean requireUnattached?: boolean + switchCondition?: PtyFencedTerminationRequest['switchCondition'] } } diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index ad0e460994a6..d66a4be93664 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -226,6 +226,7 @@ describe('a window view of a pane main is starting', () => { }) ) const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + records.getRecord = (sessionId) => (sessionId === chat.sessionId ? chat : null) const list = vi.fn(() => [agentSessionConversationView(chat)]) records.listConversations = list records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts index 0e9720853b05..23e18c138d9a 100644 --- a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -77,6 +77,7 @@ describe('a pane restore of a conversation a native chat continues', () => { throw new Error('records unavailable') } const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.getRecord = (sessionId) => (sessionId === chat.sessionId ? chat : null) store.listConversations = () => [agentSessionConversationView(chat)] store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs }) return store diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index 79399fe2b4db..836ae16f1047 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -153,7 +153,11 @@ function finalizePtyExitForRenderer( session.mainWindow.webContents.send('pty:exit', { ...payload, hostId: resolvePtyExecutionHost(payload.id), - ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('reversible') + ? { preserveRendererBinding: true } + : intentionalStops.includes('closing') + ? { preserveRendererBinding: 'closing' } + : {}), ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index 76aa742ef340..176670a6de09 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -1,3 +1,4 @@ +import type { ResumeSpawnAdmission, ResumeSpawnRefusal } from './types' import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, @@ -23,6 +24,8 @@ import { terminalConversationKey } from '../../../native-chat/structured-conversation-key' +export type { ResumeSpawnAdmission, ResumeSpawnRefusal } from './types' + export type ResumeSpawnArgs = { connectionId?: string | null command?: string @@ -30,7 +33,7 @@ export type ResumeSpawnArgs = { launchConfig?: SleepingAgentLaunchConfig launchToken?: unknown resumeProviderSession?: AgentProviderSessionMetadata - /** `saved`: the resume was built from a pane's saved facts, not asked for by the user. */ + /** Saved pane restore or explicit same-id switch from a native chat. */ resumeOrigin?: unknown env?: Record } @@ -44,18 +47,6 @@ type AdmissionRuntime = { openAgentSessionRecordStore(): Promise } -/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: - * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ -export type ResumeSpawnRefusal = { - sourceRecord?: SleepingAgentSessionRecord - verdict: 'chat-home' | 'chat-writing' | 'unverifiable' - refused: RefusedPaneResume -} - -export type ResumeSpawnAdmission = - | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } - | ResumeSpawnRefusal - /** Saved-home policy only; ordinary terminal starts remain hook-observed. */ export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, @@ -66,7 +57,7 @@ export async function admitResumeSpawn( const agent = args.launchAgent // Native chats run only natively, so a remote or WSL conversation is never a chat's. if ( - args.resumeOrigin !== 'saved' || + (args.resumeOrigin !== 'saved' && args.resumeOrigin !== 'switch') || !providerSession || !agent || args.connectionId || @@ -77,7 +68,7 @@ export async function admitResumeSpawn( if (!runtime) { return { verdict: 'unverifiable', refused: { agent, providerSession } } } - return judgeResumes(runtime, [{ agent, providerSession }]) + return judgeResumes(runtime, [{ agent, providerSession }], args.resumeOrigin === 'switch') } /** A reattach that found its session gone returns a fresh shell its window then resumes from the @@ -117,16 +108,13 @@ export async function admitColdRestoreReattach( async function judgeResumes( runtime: AdmissionRuntime, - resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]] + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], + switchStart = false ): Promise { const keysOf = ({ agent, providerSession }: RefusedPaneResume) => [providerSession.id, providerSession.transcriptPath].flatMap((id) => id?.trim() ? [terminalConversationKey(agent, id)] : [] ) - const admitted = (): ResumeSpawnAdmission => ({ - verdict: 'admitted', - refused: resumes[0] - }) let store: AgentSessionRecordStore try { store = await runtime.openAgentSessionRecordStore() @@ -145,9 +133,18 @@ async function judgeResumes( const holders = heads.filter((holding) => keysOf(refused).some((key) => keysMayMatch(holding.key, key)) ) - if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.ownerObservation))) { + if ( + holders.some((holding) => + agentSessionLeasePossiblyWriting( + store.getRecord(holding.sessionId)?.lease ?? holding.ownerObservation + ) + ) + ) { return { verdict: 'chat-writing', refused } } + if (switchStart) { + continue + } if (holders.length > 0 && (!tabs || !tabs.present)) { return { verdict: 'unverifiable', refused } } @@ -155,7 +152,7 @@ async function judgeResumes( return { verdict: 'chat-home', refused } } } - return admitted() + return { verdict: 'admitted', refused: resumes[0] } } /** A refused resume becomes the pane's plain shell: nothing of the agent launch is left to run, diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 58e38f9cd4cc..cd4ead3541f6 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -1,5 +1,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { NetworkProxySettings } from '../../../../shared/network-proxy' import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' @@ -74,3 +78,15 @@ export function allocatePtyLifecycleSequence(): number { export type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise + +/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: + * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ +export type ResumeSpawnRefusal = { + sourceRecord?: SleepingAgentSessionRecord + verdict: 'chat-home' | 'chat-writing' | 'unverifiable' + refused: RefusedPaneResume +} + +export type ResumeSpawnAdmission = + | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } + | ResumeSpawnRefusal diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 95e935244329..700ec6efb773 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -8,7 +8,7 @@ import { getPtyIpc } from '../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' -import type { IPtyProvider } from '../../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { ptyIncarnationById } from '../provider/ownership-state' import { @@ -26,11 +26,7 @@ export type PtyKillIpcDeps = { store?: Store runtime?: OrcaRuntimeService getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise | undefined - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void } diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts index d70a3556b618..529b272ebd5b 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -7,19 +7,32 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcDeps } from './spawn-types' +import { createPaneBindingStore } from '../pty-launch-race-test-fixture' +import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) const PTY_ID = 'orca-ipc-pty-run-facts' const INCARNATION_ID = 'inc-ipc-run-facts' +const LEAF_ID = 'a1111111-1111-4111-8111-111111111111' async function commit( - persistPtyBinding: ReturnType, + persistPtyBinding: ( + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null) + ) => Promise, prepare?: (ctx: ReturnType) => void ) { const facts = new TerminalRunFactsRegister() const stops = new TerminalIntentionalStops() stops.mark(PTY_ID, 'reversible', null)(true) + const store = createPaneBindingStore() + const saveBinding = store.persistPtyBinding.getMockImplementation()! + store.persistPtyBinding.mockImplementation(async (input) => { + if ((await persistPtyBinding(input)) === false) { + return false + } + return saveBinding(input) + }) const runtime = { terminalRunFacts: facts, intentionalPtyStops: stops, @@ -34,7 +47,7 @@ async function commit( } const ports = { runtime, - store: { persistPtyBinding }, + store, options: {}, sendPtySpawnedToRenderer: vi.fn() } @@ -43,11 +56,11 @@ async function commit( const ctx = createPtyIpcSpawnState(deps, { worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1', + leafId: LEAF_ID, cols: 120, rows: 40 }) - ctx.validatedLeafId = 'leaf-1' + ctx.validatedLeafId = LEAF_ID ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } prepare?.(ctx) @@ -87,7 +100,7 @@ describe('renderer spawn commit: run facts', () => { const { outcome, reply } = await commit(vi.fn().mockResolvedValue(true), (ctx) => { ctx.resumeAdmission = { verdict: 'chat-home', refused } if (earlyExit) { - ctx.metadataLeafId = 'leaf-1' + ctx.metadataLeafId = LEAF_ID ctx.deps.runtime!.getPtyLivenessVerdict = vi.fn(() => ({ status: 'exited' as const })) ctx.deps.store!.runDurableMutation = vi.fn().mockResolvedValue(true) ctx.deps.runtime!.registerPty = vi.fn(() => { diff --git a/src/main/ipc/pty/pane/resume-launch-identity.ts b/src/main/ipc/pty/pane/resume-launch-identity.ts index 81fb06fcac18..c016376810a1 100644 --- a/src/main/ipc/pty/pane/resume-launch-identity.ts +++ b/src/main/ipc/pty/pane/resume-launch-identity.ts @@ -21,6 +21,8 @@ export function seedResumeLaunchIdentityFromSpawn(args: { if ( !args.paneKey || args.resumeArgvDropped || + args.result.agentResumeRefused || + args.result.agentResumeUnavailable || args.result.isReattach === true || args.connectionId || !args.launchedAt diff --git a/src/main/ipc/pty/provider/shutdown-detect.ts b/src/main/ipc/pty/provider/shutdown-detect.ts index b43928355aca..70828ff49c84 100644 --- a/src/main/ipc/pty/provider/shutdown-detect.ts +++ b/src/main/ipc/pty/provider/shutdown-detect.ts @@ -4,10 +4,15 @@ import { ptyIncarnationById } from './ownership-state' export async function shutdownProviderAndDetectExit( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ): Promise { let providerExitObserved = false - const expectedIncarnationId = ptyIncarnationById.get(id) + const expectedIncarnationId = opts.expectedIncarnationId ?? ptyIncarnationById.get(id) const unsubscribe = provider.onExit((payload) => { if ( payload.id === id && diff --git a/src/main/ipc/pty/pty-ipc-mock-registry.ts b/src/main/ipc/pty/pty-ipc-mock-registry.ts index a841e2bdbf57..66ea77d16cfa 100644 --- a/src/main/ipc/pty/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty/pty-ipc-mock-registry.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' import type * as Wsl from '../../wsl' import type * as FsPromises from 'node:fs/promises' +import type { classifyError } from '../../telemetry/classify-error' // vi.mock hoists per file; shared factories keep every split suite on the same spy instances. export const handleMock: Mock = vi.fn() @@ -34,7 +35,7 @@ export const forgetRefusedPaneResumeMock: Mock = vi.fn() export const piBuildPtyEnvMock: Mock = vi.fn() export const piClearPtyMock: Mock = vi.fn() export const trackMock: Mock = vi.fn() -export const classifyErrorMock: Mock = vi.fn() +export const classifyErrorMock = vi.fn(() => ({ error_class: 'unknown' })) export const registerPtyMock: Mock = vi.fn() export const unregisterPtyMock: Mock = vi.fn() export const setMigrationUnsupportedPtyMock: Mock = vi.fn() @@ -135,6 +136,8 @@ export const mimoHookServiceModuleMock = (): { export const noChatsRuntimeMembers = () => ({ openAgentSessionRecordStore: vi.fn(async () => ({ listRecords: () => [], + listConversations: () => [], + getRecord: () => null, getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) })) }) diff --git a/src/main/ipc/pty/pty-ipc-suite-environment.ts b/src/main/ipc/pty/pty-ipc-suite-environment.ts index 80da663b3af3..da89bcbfce5d 100644 --- a/src/main/ipc/pty/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty/pty-ipc-suite-environment.ts @@ -156,7 +156,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { wslUncDirectoryExistsAsyncMock.mockReset() wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) trackMock.mockReset() - classifyErrorMock.mockReset() + classifyErrorMock.mockReset().mockReturnValue({ error_class: 'unknown' }) registerPtyMock.mockReset() unregisterPtyMock.mockReset() setMigrationUnsupportedPtyMock.mockReset() diff --git a/src/main/ipc/pty/pty-launch-race-parity.test.ts b/src/main/ipc/pty/pty-launch-race-parity.test.ts index 2ce06d43e68e..463efe6b9179 100644 --- a/src/main/ipc/pty/pty-launch-race-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-race-parity.test.ts @@ -717,11 +717,14 @@ describe('launch race parity, both spawn lanes', () => { shutdown }) const runtime = createLaunchRaceRuntime() - const store = { - persistPtyBinding: vi.fn(async () => { - throw new Error('disk full') - }) - } + const store = createPaneBindingStore() + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }) + store.persistPtyBinding.mockRejectedValue(new Error('disk full')) const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime, store }) forgetPtyAfterTest(ptyId) @@ -758,6 +761,7 @@ describe('launch race parity, both spawn lanes', () => { }) ) const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + records.getRecord = (sessionId) => (sessionId === chat.sessionId ? chat : null) records.listConversations = () => [agentSessionConversationView(chat)] records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) const forget = vi.fn(async () => {}) diff --git a/src/main/ipc/pty/pty-pane-materialization-race.test.ts b/src/main/ipc/pty/pty-pane-materialization-race.test.ts index 090ba86f516e..75da5d3ff8ec 100644 --- a/src/main/ipc/pty/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty/pty-pane-materialization-race.test.ts @@ -9,6 +9,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import { registerPtyHandlers } from './register-handlers' import { getPtyIdForPaneKey } from './pane/key-state' import { setLocalPtyProvider } from './provider/registry' +import { createPaneBindingStore } from './pty-launch-race-test-fixture' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -312,9 +313,7 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - const store = { - persistPtyBinding: vi.fn() - } + const store = createPaneBindingStore() let registeredPane: { ptyId: string; tabId: string; leafId: string } | null = null let controller: RuntimeSpawnController | null = null const runtime = { @@ -509,12 +508,18 @@ describe('registerPtyHandlers', () => { projectGroupId: 'folder-group' } const store = { - persistPtyBinding: vi.fn(), + ...createPaneBindingStore(), getFolderWorkspace: vi.fn(() => folderWorkspace), getFolderWorkspaces: vi.fn(() => [folderWorkspace]), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) } + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: worktreeId, + tabId, + leafId + }) const prepareClaudeAuth = vi.fn(() => { throw new Error('replacement auth preflight must not run') }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8ae616e7a41a..7f274f910af1 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -1,7 +1,7 @@ import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' -import type { IPtyProvider } from '../../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' import type { @@ -44,11 +44,7 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & { lastTitle?: string kittyKeyboardFlags?: number } | null> - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 9fd9e8775317..fc267602336a 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -1,6 +1,6 @@ import { adoptMaterializedRuntimePtySpawn } from './spawn-early' import { createRuntimePtySpawnState } from './spawn-state' -import { localProvider } from '../provider/registry' +import { getProviderForPty, localProvider } from '../provider/registry' import { getLocalAutomaticWorkStatus, localTerminalLimit, @@ -71,6 +71,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Attach-only ingestion serves unmounted daemon sessions without creating/resizing; SSH uses leases and local PTYs already stream. attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), + killFenced: (request) => + getProviderForPty(request.id).killFenced?.(request) ?? + Promise.resolve({ outcome: 'refused', reason: 'unsupported' }), kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed, incarnationId) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed, incarnationId), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index cc7c39c1f1bb..d15c3efdd370 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -180,7 +180,7 @@ export function retireRejectedPtyFromRuntimeController( export async function stopAndWaitPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: { keepHistory?: boolean; deadlineMs?: number; expectedIncarnationId?: string } ): Promise { // A final relay stop is owed until the relay proves it, so an unproven one still reads as closing. const relayOwesStop = @@ -200,7 +200,7 @@ export async function stopAndWaitPtyFromRuntimeController( async function stopAndWaitPty( deps: PtyRuntimeControllerDeps, ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: Parameters[2] ): Promise { const { runtime, @@ -211,6 +211,9 @@ async function stopAndWaitPty( } = deps runtime?.markPtyStopRequested?.(ptyId) const connectionId = getPtySshConnectionId(ptyId) + if (opts?.expectedIncarnationId && ptyIncarnationById.get(ptyId) !== opts.expectedIncarnationId) { + return false + } if (connectionId && isLegacyRelayBridgedPty(ptyId)) { // A reversible stop must not leave the bridge's durable stop behind, so the pane stays live. if (opts?.keepHistory) { @@ -252,6 +255,9 @@ async function stopAndWaitPty( await startupPromise } } + if (opts?.expectedIncarnationId && ptyIncarnationById.get(ptyId) !== opts.expectedIncarnationId) { + return false + } let provider: IPtyProvider try { provider = getProviderForPty(ptyId) @@ -270,7 +276,8 @@ async function stopAndWaitPty( providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { immediate: true, keepHistory: opts?.keepHistory ?? false, - deadlineMs + deadlineMs, + ...(opts?.expectedIncarnationId ? { expectedIncarnationId: opts.expectedIncarnationId } : {}) }) } catch (err) { if (!isPtyAlreadyGoneError(err)) { diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.ts b/src/main/ipc/pty/runtime/spawn-commit-response.ts index 2ea9dcfc435d..0a30d2e5ecb4 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.ts @@ -2,10 +2,10 @@ import type { RuntimePtySpawnState } from './spawn-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { PaneResumeOutcome } from '../../../../shared/workspace-layout/workspace-layout-published' -type RuntimeResumeOutcomeState = Pick< - RuntimePtySpawnState, - 'args' | 'result' | 'codexResumeArgvDropped' -> & { codexResumeUnavailable?: boolean } +type RuntimeResumeOutcomeState = Pick & { + args: Pick + codexResumeUnavailable?: boolean +} /** Whether a fresh start resumed the agent it was asked to; a reattach ran no launch command. */ export function resumeOutcomeOf(ctx: RuntimeResumeOutcomeState): PaneResumeOutcome | null { diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts index e18292ef4aee..a223e97b68ea 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -8,9 +8,11 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { commitRuntimePtySpawn } from './spawn-commit' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' import type { PtyRuntimeControllerDeps } from './controller-deps' +import { createPaneBindingStore } from '../pty-launch-race-test-fixture' const PTY_ID = 'orca-pty-run-facts' const INCARNATION_ID = 'inc-run-facts' +const LEAF_ID = 'b1111111-1111-4111-8111-111111111111' const ADOPTED = { disposition: 'adopted', @@ -19,7 +21,7 @@ const ADOPTED = { generation: 'g1', phase: 'live', ptyId: PTY_ID, - surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: LEAF_ID, terminalHandle: 'h1' } } } @@ -116,7 +118,15 @@ describe('runtime spawn commit: run facts', () => { 'reports unavailable before binding even when the request was erased (%s)', async (erased) => { const onResumeOutcome = vi.fn() - const persistPtyBinding = vi.fn(async () => { + const store = createPaneBindingStore() + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: 'wt-1', + tabId: 'tab-1', + leafId: LEAF_ID + }) + const persistPtyBinding = store.persistPtyBinding + store.bindingWrites.mockImplementation(() => { expect(onResumeOutcome.mock.calls).toEqual( ORCA_LAYOUT_OWNER_IS_MAIN ? [[PTY_ID, 'resume-unavailable']] : [] ) @@ -132,13 +142,11 @@ describe('runtime spawn commit: run facts', () => { ctx.codexResumeUnavailable = !erased ctx.codexResumeArgvDropped = true ctx.hostSessionBinding = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this boundary pin observes the bind write without invoking its stored callback. - store: { persistPtyBinding } as unknown as NonNullable< - typeof ctx.hostSessionBinding - >['store'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the binding session and durable callback contract used here. + store: store as unknown as NonNullable['store'], worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1' + leafId: LEAF_ID } }) @@ -150,19 +158,24 @@ describe('runtime spawn commit: run facts', () => { const facts = new TerminalRunFactsRegister() const stops = new TerminalIntentionalStops() stops.mark(PTY_ID, 'reversible', null)(true) - const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const store = createPaneBindingStore() + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: 'wt-1', + tabId: 'tab-1', + leafId: LEAF_ID + }) + const persistPtyBinding = store.persistPtyBinding.mockRejectedValue(new Error('disk full')) await expect( commit({}, facts, stops, (ctx) => { ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } ctx.hostSessionBinding = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. - store: { persistPtyBinding } as unknown as NonNullable< - typeof ctx.hostSessionBinding - >['store'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the binding session and failing durable writer used here. + store: store as unknown as NonNullable['store'], worktreeId: 'wt-1', tabId: 'tab-1', - leafId: 'leaf-1' + leafId: LEAF_ID } }) ).rejects.toThrow() diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index c9e5688fc9eb..25dce5093286 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -88,7 +88,10 @@ export async function buildRuntimePtySpawnOptions( store: ctx.deps.store, claudeAuth: ctx.claudeAuth }) - ctx.spawnOptions = launch.spawnOptions + ctx.spawnOptions = { + ...launch.spawnOptions, + ...(args.switchExecution ? { switchExecution: args.switchExecution } : {}) + } ctx.env = launch.env ctx.launchCommand = launch.launchCommand ctx.provisionalPtySize = seedPtySpawnSize({ diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index 8e285b7593d0..fc17314e0d85 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -68,6 +68,7 @@ const chat = agentSessionRecordFixture( ) const store = { + getRecord: (sessionId: string) => (sessionId === chat.sessionId ? chat : null), listConversations: () => [agentSessionConversationView(chat)], getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] }) } @@ -75,7 +76,7 @@ const store = { /** A host whose one chat continues C at rest, with its tab open. */ function chatHost() { return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the two members `store` has. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the three members `store` has. openAgentSessionRecordStore: async () => store as unknown as AgentSessionRecordStore, getTerminalLaunchResult: vi.fn(), forgetRefusedSavedResume: vi.fn(async () => {}), @@ -175,6 +176,10 @@ describe('actual runtime saved-resume outcomes', () => { handle: { ...link.handle, nativeId: `unrelated-${index}` } })) })) + const recordsBySessionId = new Map(records.map((record) => [record.sessionId, record])) + const getRecord = vi + .spyOn(store, 'getRecord') + .mockImplementation((sessionId) => recordsBySessionId.get(sessionId) ?? null) const list = vi .spyOn(store, 'listConversations') .mockImplementation(() => records.map(agentSessionConversationView)) @@ -243,6 +248,7 @@ describe('actual runtime saved-resume outcomes', () => { spawn.mockRestore() read.mockRestore() list.mockRestore() + getRecord.mockRestore() visible.mockRestore() } } @@ -411,6 +417,7 @@ it('preserves case-distinct conversation heads and saved homes across the shared })) } const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + opencodeStore.getRecord = (sessionId) => (sessionId === record.sessionId ? record : null) opencodeStore.listConversations = () => [agentSessionConversationView(record)] opencodeStore.getVisibleSessionTabIndex = () => ({ present: true, diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 5fc203b21562..4d92277a2ba5 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -33,6 +33,7 @@ import type { import { localProvider } from '../provider/registry' import type { PaneResumeOutcome } from '../../../../shared/workspace-layout/workspace-layout-published' import type { StartupCwdFallback } from '../../../../shared/startup-cwd-fallback' +import type { AgentSessionSwitchAuthority } from '../../../../shared/agent-session-switch-execution' export type RuntimePtySpawnState = { deps: PtyRuntimeControllerDeps @@ -129,7 +130,7 @@ export type RuntimePtySpawnArgs = { envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' /** The user's own act (a CLI command, a paired client's create, a launch); without it a stopped WSL distro holds the start. */ wslUserAct?: true connectionId?: string | null @@ -160,6 +161,7 @@ export type RuntimePtySpawnArgs = { agentSessionCreateOperationId?: string spawnIntent?: SpawnIntent signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void /** Publish fresh resume outcome before writing its pane binding. */ diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 81e9fc3218b4..fc70c38ec194 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -2,7 +2,7 @@ import type { BrowserWindow, WebContents } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { IPtyProvider } from '../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from './provider/shutdown-detect' import type { PtyModelRestoreReason } from '../../../shared/pty-model-restore-marker' import type { PtyDeliveryWriteOff, @@ -146,11 +146,7 @@ export type PtyIpcSession = { ptyId: string, opts?: { scrollbackRows?: number } ) => Promise - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void consumeSyntheticKillExit: (id: string, incarnationId?: string) => boolean diff --git a/src/main/ipc/pty/spawn-commit-adopted-session.ts b/src/main/ipc/pty/spawn-commit-adopted-session.ts new file mode 100644 index 000000000000..cbf90d05db33 --- /dev/null +++ b/src/main/ipc/pty/spawn-commit-adopted-session.ts @@ -0,0 +1,87 @@ +import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session-host-authority' +import type { PtySpawnOutcome } from './spawn-outcome' +import { codexReattachedHomeRouteField } from './host-env/codex-home' +import { commitAttachedPtySize } from './delivery/attached-pty-size' +import type { admitProviderReattachLaunchIdentity } from './pane/launch-authority' +import { registerPersistedPtySpawn } from './pane/spawn-registration' +import { savePtySpawnPaneBinding } from './spawn-commit-binding' +import type { PtySpawnCommitInput } from './spawn-commit-assembly' + +export async function commitAdoptedAgentSession( + input: PtySpawnCommitInput, + adopted: AgentSessionClaimedSpawnResult, + providerReattachLaunchIdentity: ReturnType +): Promise { + const { state } = input + const { result } = state + const runtime = state.deps.runtime + let surface = adopted.owner.surface + if (state.args.switchExecution) { + const current = runtime?.resolveLiveTerminalSurface(result.id, result.incarnationId) + if (current) { + surface = current + } else if (input.registration.store) { + const binding = { + worktreeId: surface.worktreeId, + tabId: surface.tabId, + leafId: surface.leafId, + ptyId: result.id, + incarnationId: result.incarnationId, + mayCreate: false + } + await savePtySpawnPaneBinding( + state, + { + store: input.registration.store, + binding, + refusal: 'terminal_pane_owner_changed', + failureLog: 'Failed to reconcile attached terminal placement' + }, + state.args.connectionId + ) + surface = { ...surface, tabId: binding.tabId, leafId: binding.leafId } + } + result.switchRecoveredSurface = { tabId: surface.tabId, leafId: surface.leafId } + } + // Adoption proves attach even without the reply flag; size commit and joiners must agree. + const adoptedResult = { ...result, isReattach: true } + const rejectedRegistration = registerPersistedPtySpawn( + runtime, + input.registration.store, + state.pendingRegistrationClaim, + result.id, + surface.worktreeId, + null, + { + tabId: surface.tabId, + leafId: surface.leafId, + terminalHandle: surface.terminalHandle, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) + } + ) + if (rejectedRegistration) { + return rejectedRegistration + } + runtime?.noteTerminalSpawnCommit?.(result, input.expectedSourceBinding) + runtime?.registerPreAllocatedHandleForPty(result.id, surface.terminalHandle) + state.deps.options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: state.selectedCodexHomePath, + reattached: true, + startedAt: state.codexHomeLaunchStartedAt, + startedSequence: state.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(state.reattachedCodexHomeRoutes, result.id, true), + ...(input.codexLaunchEnv ? { launchEnv: input.codexLaunchEnv } : {}) + }) + commitAttachedPtySize({ + result: adoptedResult, + requested: { cols: state.args.cols, rows: state.args.rows }, + cachedBeforeAttach: state.provisionalPtySize?.cachedBeforeAttach, + reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime) + }) + return { + kind: 'adopted', + result: { ...input.response, ...adoptedResult, agentSessionEnsure: adopted } + } +} diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index 0d1a6cdc16c4..2d99ba6f2024 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { PtySpawnSizeSeed } from './spawn-options-assembly' import { isValidTerminalTabId } from '../../../shared/terminal-tab-id' import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session-host-authority' @@ -12,11 +13,7 @@ import type { CodexPaneHomeRoute } from '../../codex/codex-pane-account-registry import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' import type { PtySpawnTiming } from './pty-spawn-timing' import type { CodexHomePtySpawnedLifecycleArgs } from './host-env/types' -import { - codexReattachedHomeRouteField, - shouldSkipCodexHomeEnvForWindowsShell -} from './host-env/codex-home' -import { commitAttachedPtySize } from './delivery/attached-pty-size' +import { shouldSkipCodexHomeEnvForWindowsShell } from './host-env/codex-home' import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners' import { admitProviderReattachLaunchIdentity } from './pane/launch-authority' import type { ReplacedPaneOwner } from './pane/replaced-pane-owner' @@ -26,8 +23,13 @@ import type { StablePaneOwner } from './pane/stable-owner' import { seedHeadlessTerminalFromSpawnResult } from './pane/terminal-spawn-restore' import type { RuntimePtySpawnArgs } from './runtime/spawn-state' import { reportRuntimeResumeOutcome } from './runtime/spawn-commit-response' -import { savePtySpawnPaneBinding, type PtySpawnPaneBinding } from './spawn-commit-binding' +import { + closeUnusedSwitchSpawn, + savePtySpawnPaneBinding, + type PtySpawnPaneBinding +} from './spawn-commit-binding' import { publishCommittedPtySpawn, type PtySpawnPaneKeyAliases } from './spawn-commit-publication' +import { commitAdoptedAgentSession } from './spawn-commit-adopted-session' export type PtySpawnCommitState = { deps: { @@ -49,7 +51,8 @@ export type PtySpawnCommitState = { | 'resumeProviderSession' | 'onResumeOutcome' | 'telemetry' - > + | 'switchExecution' + > & { resumeOrigin?: unknown } result: PtySpawnResult provider: IPtyProvider cwd: string | undefined @@ -111,6 +114,23 @@ export async function commitPtySpawn(input: PtySpawnCommitInput): Promise -): Promise { - const { state } = input - const { result } = state - const runtime = state.deps.runtime - // Adoption proves attach even without the reply flag; size commit and joiners must agree. - const adoptedResult = { ...result, isReattach: true } - const { surface } = adopted.owner - const rejectedRegistration = registerPersistedPtySpawn( - runtime, - input.registration.store, - state.pendingRegistrationClaim, - result.id, - surface.worktreeId, - null, - { - tabId: surface.tabId, - leafId: surface.leafId, - terminalHandle: surface.terminalHandle, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) - } - ) - if (rejectedRegistration) { - return rejectedRegistration - } - runtime?.noteTerminalSpawnCommit?.(result, input.expectedSourceBinding) - runtime?.registerPreAllocatedHandleForPty(result.id, surface.terminalHandle) - state.deps.options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: state.selectedCodexHomePath, - reattached: true, - startedAt: state.codexHomeLaunchStartedAt, - startedSequence: state.codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(state.reattachedCodexHomeRoutes, result.id, true), - ...(input.codexLaunchEnv ? { launchEnv: input.codexLaunchEnv } : {}) - }) - commitAttachedPtySize({ - result: adoptedResult, - requested: { cols: state.args.cols, rows: state.args.rows }, - cachedBeforeAttach: state.provisionalPtySize?.cachedBeforeAttach, - reflowHeadlessTerminalToPtyGrid: runtime?.reflowHeadlessTerminalToPtyGrid?.bind(runtime) - }) - return { - kind: 'adopted', - result: { ...input.response, isReattach: true, agentSessionEnsure: adopted } - } -} diff --git a/src/main/ipc/pty/spawn-commit-binding.ts b/src/main/ipc/pty/spawn-commit-binding.ts index a26731b2ca47..8b964d5e0608 100644 --- a/src/main/ipc/pty/spawn-commit-binding.ts +++ b/src/main/ipc/pty/spawn-commit-binding.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import type { Store } from '../../persistence' import type { PersistPtyBindingArgs } from '../../persistence/loading-store/pty-binding-persistence' import { ptyBindingIsRefused } from '../../persistence/loading-store/pty-binding-refusals' @@ -13,7 +14,10 @@ import { } from './pane/pane-spawn-operation' import { swapReplacedPaneBinding } from './pane/pane-owner-replacement' import type { ReplacedPaneOwner } from './pane/replaced-pane-owner' +import { finishPtyShutdown } from './provider/liveness' +import { PtyExitProof } from '../../pty-exit-proof' import { discardUnpersistedPtySpawn } from './pane/spawn-registration' +import type { AgentSessionSwitchAuthority } from '../../../shared/agent-session-switch-execution' /** A lane's pane binding for its new process, saved before the spawn is acknowledged. */ export type PtySpawnPaneBinding = { @@ -31,16 +35,29 @@ export async function savePtySpawnPaneBinding( result: PtySpawnResult provider: IPtyProvider replacedPaneOwner?: ReplacedPaneOwner | null - deps: { runtime?: OrcaRuntimeService } + args?: { + switchExecution?: AgentSessionSwitchAuthority + } + deps: { runtime?: OrcaRuntimeService; sendPtySpawnedToRenderer?: (id: string) => void } }, { store, binding, absentOwner, refusal, failureLog }: PtySpawnPaneBinding, connectionId: string | null | undefined ): Promise { const replaced = state.replacedPaneOwner + const observeAuthority = () => { + try { + state.args?.switchExecution?.check() + } catch { + state.result.switchOutcomeUnknown = true + } + } try { const persisted = replaced - ? await store.persistPtyBinding(swapReplacedPaneBinding(store, binding, replaced)) - : await persistFreshPaneBinding(store, binding, connectionId, absentOwner) + ? await store.persistPtyBinding(() => { + observeAuthority() + return swapReplacedPaneBinding(store, binding, replaced)() + }) + : await persistFreshPaneBinding(store, binding, connectionId, absentOwner, observeAuthority) if (persisted === false) { throw new Error(refusal) } @@ -50,6 +67,31 @@ export async function savePtySpawnPaneBinding( } } catch (err) { console.error(failureLog, err) + if (state.args?.switchExecution) { + if (await closeUnusedSwitchSpawn(state.provider, state.result)) { + throw err + } + // A dispatched child can have acquired users during the durable wait; never generic-close it. + state.result.switchOutcomeUnknown = true + const recovered = { tabId: randomUUID(), leafId: randomUUID() } + // Rehome the real child through ordinary topology creation, never overwrite the winner. + const recovery: PersistPtyBindingArgs = { + worktreeId: binding.worktreeId, + ...recovered, + ptyId: binding.ptyId, + incarnationId: binding.incarnationId, + startupCwd: binding.startupCwd, + mayCreate: true + } + await store + .persistPtyBinding(recovery, connectionId ? toSshExecutionHostId(connectionId) : undefined) + .catch(() => false) + // Even a failed disk remains an owned, visibly published runtime terminal, not a second start. + state.result.switchRecoveredSurface = recovered + Object.assign(binding, recovery) + state.replacedPaneOwner = null + return + } await discardUnpersistedPtySpawn(state.provider, state.result, state.deps.runtime) if ( err instanceof PaneOwnerContentionError || @@ -63,17 +105,38 @@ export async function savePtySpawnPaneBinding( } } +type PaneBindingStore = Pick + /** Runs inside the existing durable mutation, after source/topology checks and before any bind. */ -export async function persistFreshPaneBinding( - store: Store, - binding: PersistPtyBindingArgs, +export function persistFreshPaneBinding( + store: PaneBindingStore, + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs), connectionId?: string | null, - absentOwner?: AbsentPaneBinding -): Promise { + absentOwner?: AbsentPaneBinding, + observeAuthority?: () => void +): Promise +export function persistFreshPaneBinding( + store: Partial | null | undefined, + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs), + connectionId?: string | null +): Promise | undefined +export function persistFreshPaneBinding( + store: Partial | null | undefined, + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs), + connectionId?: string | null, + absentOwner?: AbsentPaneBinding, + observeAuthority?: () => void +): Promise | undefined { + if (!store?.getWorkspaceSession) { + return undefined + } + const getWorkspaceSession = store.getWorkspaceSession.bind(store) let targetChanged = false const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const persisted = await store.persistPtyBinding(() => { - const session = store.getWorkspaceSession(hostId) + const pending = store.persistPtyBinding?.(() => { + observeAuthority?.() + const binding = typeof input === 'function' ? input() : input + const session = getWorkspaceSession(hostId) const paneKey = makePaneKey(binding.tabId, binding.leafId) if (ptyBindingIsRefused(binding, session, binding.worktreeId, paneKey, [])) { return binding @@ -90,8 +153,51 @@ export async function persistFreshPaneBinding( } return binding }, hostId) - if (targetChanged) { - throw new PaneOwnerContentionError() + return pending === undefined + ? undefined + : Promise.resolve(pending).then((persisted) => { + if (targetChanged) { + throw new PaneOwnerContentionError() + } + return persisted + }) +} + +/** Only the original unused physical witness can authorize cleanup after a dispatched result. */ +export async function closeUnusedSwitchSpawn( + provider: IPtyProvider, + result: PtySpawnResult +): Promise { + if ( + !result.switchCleanupWitness || + !result.incarnationId || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' + ) { + return false + } + const cleanup = await provider + .killFenced?.({ + id: result.id, + expectedIncarnationId: result.incarnationId, + requireNoAgentOwner: false, + requireUnattached: false, + switchCondition: { + token: result.switchCleanupWitness, + remainingMs: 8_000, + ownedSpawn: true, + claimGeneration: result.agentSessionEnsure?.owner.generation + } + }) + .catch(() => undefined) + if (cleanup?.outcome !== 'ended' && cleanup?.outcome !== 'already_exited') { + return false } - return persisted + finishPtyShutdown( + result.id, + null, + undefined, + PtyExitProof.from('shutdown-acknowledged', result.incarnationId) + ) + return true } diff --git a/src/main/ipc/pty/spawn-commit-publication.ts b/src/main/ipc/pty/spawn-commit-publication.ts index bb7203a26208..0a1d30abc3e6 100644 --- a/src/main/ipc/pty/spawn-commit-publication.ts +++ b/src/main/ipc/pty/spawn-commit-publication.ts @@ -1,3 +1,4 @@ +import { retireChatForAdmittedTerminalLaunch } from '../../runtime/session-tabs/structured-session-terminal-launch-retirement' import { agentHookServer } from '../../agent-hooks/server' import { clearMigrationUnsupportedPtysForPaneKey } from '../../agent-hooks/migration-unsupported-pty-state' import { registerPty } from '../../memory/pty-registry' @@ -28,10 +29,20 @@ export type PtySpawnPaneKeyAliases = { opaqueRemintedSpawnPaneKey: string | null } -/** Everything a registered spawn publishes, in one order for both lanes; nothing here awaits. */ -export function publishCommittedPtySpawn(input: PtySpawnCommitInput): PtySpawnOutcome { +/** Everything a registered spawn publishes, in one order for both lanes. */ +export async function publishCommittedPtySpawn( + input: PtySpawnCommitInput +): Promise { const { state } = input const { deps, args, result } = state + let switchRevoked = result.switchOutcomeUnknown === true + try { + args.switchExecution?.check() + } catch { + switchRevoked = true + } + // A dispatched result still needs its ordinary binding and visible inventory. + const runtime = deps.runtime runtime?.noteTerminalSpawnCommit?.( { ...result, spawnRequestedAt: state.spawnRequestedAt }, @@ -97,16 +108,18 @@ export function publishCommittedPtySpawn(input: PtySpawnCommitInput): PtySpawnOu : null }) // Rejected spawns must not leave a published resume identity. - seedResumeLaunchIdentityFromSpawn({ - paneKey: input.paneKey, - launchAgent: args.launchAgent, - resumeProviderSession: args.resumeProviderSession, - connectionId: args.connectionId, - worktreeId: args.worktreeId, - launchedAt: state.codexHomeLaunchStartedAt, - resumeArgvDropped: input.resumeArgvDropped, - result - }) + if (!switchRevoked) { + seedResumeLaunchIdentityFromSpawn({ + paneKey: input.paneKey, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + connectionId: args.connectionId, + worktreeId: args.worktreeId, + launchedAt: state.codexHomeLaunchStartedAt, + resumeArgvDropped: input.resumeArgvDropped, + result + }) + } // Inventory cannot infer background/reattached PTYs from mounted views. deps.sendPtySpawnedToRenderer(result.id) deps.options?.onCodexHomePtySpawned?.({ @@ -125,6 +138,9 @@ export function publishCommittedPtySpawn(input: PtySpawnCommitInput): PtySpawnOu ? { launchEnv: input.codexLaunchEnv } : {}) }) + if (!switchRevoked) { + await retireChatForAdmittedTerminalLaunch(runtime, args, input.response) + } return { kind: 'committed', result: input.response } } diff --git a/src/main/ipc/pty/spawn-execute.ts b/src/main/ipc/pty/spawn-execute.ts index c1119fe41a7d..06e1f0eafb81 100644 --- a/src/main/ipc/pty/spawn-execute.ts +++ b/src/main/ipc/pty/spawn-execute.ts @@ -76,6 +76,7 @@ export async function executePtySpawn( : 0 ctx.spawnTiming?.mark('options') const validateDispatch = (): void => { + args.switchExecution?.check() facts.validateDispatch?.() if (ctx.paneSpawnOperation?.mustExist && args.worktreeId && args.tabId && args.leafId) { assertPaneStillExists( @@ -148,6 +149,14 @@ export async function executePtySpawn( } }, isLive: isRegisteredAgentSessionOwnerLive, + check: args.switchExecution?.check, + onDispatchedRevoked: args.switchExecution + ? () => { + if (providerResult) { + providerResult.switchOutcomeUnknown = true + } + } + : undefined, onJoin: () => { awaitingAnotherStart = true } diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts index 37490c151cd8..3d63f814692e 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts @@ -205,6 +205,7 @@ export function subscribeRemoteRuntimeSharedControlRequest( params: unknown, timeoutMs: number, callbacks: { + beforeSend?: () => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError: (error: { code: string; message: string }) => void diff --git a/src/main/ipc/runtime-environments/runtime-environment-subscription-controls.ts b/src/main/ipc/runtime-environments/runtime-environment-subscription-controls.ts new file mode 100644 index 000000000000..22651e6e9519 --- /dev/null +++ b/src/main/ipc/runtime-environments/runtime-environment-subscription-controls.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron' +import type { + RetainedRemoteRuntimeSubscription, + PendingRuntimeSubscription +} from './runtime-environment-subscription-handlers' + +export function registerRuntimeEnvironmentSubscriptionControls( + remoteRuntimeSubscriptions: Map, + pendingSubscriptions: Map +): void { + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const pending = pendingSubscriptions.get(args.subscriptionId) + if (pending?.ownerWebContentsId === event.sender.id) { + pending.close() + return { unsubscribed: true } + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null +} diff --git a/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts index d159defbc37e..71f2216d2f57 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts @@ -3,10 +3,14 @@ import { randomUUID } from 'node:crypto' import { abortWhenRendererGone } from '../renderer-lifetime-abort' import { resolveEnvironment } from '../../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../../shared/remote-runtime/remote-runtime-client' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../../../shared/runtime-rpc-envelope' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' +import { registerRuntimeEnvironmentSubscriptionControls } from './runtime-environment-subscription-controls' export type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { setupController: AbortController @@ -53,6 +57,8 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { timeoutMs?: number subscriptionId?: string expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string } ): Promise<{ subscriptionId: string; requestId: string }> => { @@ -89,6 +95,10 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { const sender = event.sender const ownerWebContentsId = sender.id const setupController = new AbortController() + const dispatchBy = + args.dispatchObservation?.expiresAt ?? Date.now() + (args.timeoutMs ?? 30_000) + let dispatchTimer: ReturnType | undefined + let sentAt: number | undefined const associationObserver = args.method === 'agentSession.subscribeStatus' ? createAssociationObserver?.( @@ -103,6 +113,7 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { let subscription: RemoteRuntimeSubscription | null = null const rendererLifetime = abortWhenRendererGone(sender) const disposeRendererLifetime = (): void => { + clearTimeout(dispatchTimer) associationObserver?.dispose() rendererLifetime.dispose() rendererLifetime.signal.removeEventListener('abort', closeSubscription) @@ -144,6 +155,15 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { pendingSubscriptions.set(subscriptionId, pending) // A reload replaces the owning document without destroying its WebContents. rendererLifetime.signal.addEventListener('abort', closeSubscription, { once: true }) + if (args.dispatchObservation) { + dispatchTimer = setTimeout( + () => { + notifyClosed() + closeSubscription() + }, + Math.max(0, dispatchBy - Date.now()) + ) + } try { subscription = await subscribeRuntimeEnvironment( getUserDataPath(), @@ -152,6 +172,40 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { args.params, args.timeoutMs, { + beforeSend: + args.observeDispatch || args.dispatchObservation + ? () => { + if ( + !transportIsCurrent() || + setupController.signal.aborted || + Date.now() >= dispatchBy + ) { + throw new Error('request expired') + } + sentAt ??= Date.now() + if (args.dispatchObservation) { + clearTimeout(dispatchTimer) + dispatchTimer = setTimeout( + () => { + notifyClosed() + closeSubscription() + }, + Math.max( + 0, + (args.dispatchObservation.responseExpiresAt ?? + sentAt + args.dispatchObservation.responseTimeoutMs) - Date.now() + ) + ) + } + if (!sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + type: 'sent', + sentAt + }) + } + } + : undefined, onEvent: (payload) => { if ( rendererGone || @@ -169,7 +223,8 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { if (transportIsCurrent() && !sender.isDestroyed()) { sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, - ...payload + ...payload, + ...(args.observeDispatch && sentAt !== undefined ? { sentAt } : {}) }) if (payload.type === 'response' && payload.response.ok) { try { @@ -243,50 +298,5 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { return { subscriptionId, requestId: subscription.requestId } } ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const pending = pendingSubscriptions.get(args.subscriptionId) - if (pending?.ownerWebContentsId === event.sender.id) { - pending.close() - return { unsubscribed: true } - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null + registerRuntimeEnvironmentSubscriptionControls(remoteRuntimeSubscriptions, pendingSubscriptions) } diff --git a/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts index 201a5f5d65e9..4108a8fb6992 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts @@ -49,6 +49,7 @@ type SubscriptionCallbacks = { | { type: 'close' } ) => void onClose: () => void + beforeSend?: () => void } export function shouldRouteCallBySupport(method: string, environmentId: string): boolean { @@ -141,17 +142,20 @@ export async function subscribeSupportRoutedRuntimeEnvironment(args: { }): Promise { let markedUsed = false let supportOutcome: SupportRoute['outcome'] | null = null - const callbacks = subscriptionCallbacks(args, () => { - if ( - markedUsed || - !args.isCurrent() || - (supportOutcome && !isRuntimeEnvironmentCapabilityOutcomeCurrent(supportOutcome)) - ) { - return false - } - markedUsed = true - return true - }) + const callbacks = { + beforeSend: args.callbacks.beforeSend, + ...subscriptionCallbacks(args, () => { + if ( + markedUsed || + !args.isCurrent() || + (supportOutcome && !isRuntimeEnvironmentCapabilityOutcomeCurrent(supportOutcome)) + ) { + return false + } + markedUsed = true + return true + }) + } const routed = await routeRuntimeEnvironmentSubscriptionBySupport({ userDataPath: args.userDataPath, environment: args.environment, diff --git a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts index e524edd16930..a87f716d836e 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts @@ -175,6 +175,7 @@ export async function subscribeRuntimeEnvironment( | { type: 'close' } ) => void onClose: () => void + beforeSend?: () => void }, isCurrent: () => boolean = () => true, signal?: AbortSignal, @@ -196,6 +197,7 @@ export async function subscribeRuntimeEnvironment( recordRuntimeEnvironmentUsage(userDataPath, environment.id, { runtimeId }) } const callbacksWithMarkUsed = { + beforeSend: rawCallbacks.beforeSend, onResponse: (response: RuntimeRpcResponse) => { if (response.ok === true) { markUsedOnce(response._meta.runtimeId) diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 9d5a9750cbf1..66fdfd0c2413 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -24,6 +24,7 @@ import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-lim export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { peer?: JsonlRpcPeerOptions onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void + onPhysicalExit?: (exited: Promise) => void } /** The owning runtime keeps process exit evidence separate from transport closure. */ @@ -45,6 +46,7 @@ export class JsonlRpcAgentConnection { site: 'jsonl-rpc-agent-teardown' }) const managed = this.managed + options.onPhysicalExit?.(managed.exitPromise) this.peer = new JsonlRpcPeer( managed.child.stdout, managed.child.stdin, diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import-body.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import-body.ts new file mode 100644 index 000000000000..85fdd2f9e67f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import-body.ts @@ -0,0 +1,89 @@ +const MAX_LEGACY_IMPORT_SUBAGENTS = 64 +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../subagent-entry-id-bounds' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import { + boundInlineText, + boundToolInput, + type JournalPayloadLimits +} from './journal-payload-bounds' +export const LEGACY_IMPORT_BLOCK_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } + +export function replacementItems( + messages: readonly NativeChatMessage[], + identities: readonly AgentJournalItemIdentity[], + limits: JournalPayloadLimits +): JournalReplacementItem[] { + const replacement: JournalReplacementItem[] = [] + for (const [index, message] of messages.entries()) { + const identity = identities[index] + if (identity) { + replacement.push(replacementItem(message, identity, limits)) + } + } + return replacement +} + +export function replacementItem( + message: NativeChatMessage, + identity: AgentJournalItemIdentity, + limits: JournalPayloadLimits +): JournalReplacementItem { + return { + identity, + body: legacyItemBody(message, limits), + observedAt: message.timestamp ?? undefined, + // Imported history belongs to no active generation, even when saved rows claim to be running. + ownerFence: 0 + } +} + +export function legacyItemBody( + message: NativeChatMessage, + limits?: JournalPayloadLimits +): AgentJournalItemBody { + const only = message.blocks.length === 1 ? message.blocks[0] : undefined + if (only?.type === 'tool-call') { + // Bound adoption before epoch publication; catch-up uses whole-batch admission. + return { + kind: 'tool-call', + name: only.name, + input: limits ? boundToolInput(only.input, limits) : only.input, + ...(only.callId !== undefined ? { callId: only.callId } : {}), + state: 'completed' + } + } + return { + kind: 'message', + role: message.role, + blocks: limits ? message.blocks.map((block) => boundBlock(block, limits)) : message.blocks + } +} + +export function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock { + if (block.type === 'text') { + return { ...block, text: boundInlineText(block.text, limits).text } + } + if (block.type === 'tool-result') { + return { ...block, output: boundInlineText(block.output, limits).text } + } + if (block.type === 'tool-call') { + return { ...block, input: boundToolInput(block.input, limits) } + } + if (block.type === 'subagent-group') { + return { + ...block, + agents: block.agents.slice(0, MAX_LEGACY_IMPORT_SUBAGENTS).map((agent) => ({ + ...agent, + // Digest-based roster IDs keep children with shared prefixes distinct. + id: boundSubagentEntryId(agent.id), + label: boundInlineText(agent.label, limits).text + })) + } + } + return block +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import-contract.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import-contract.ts new file mode 100644 index 000000000000..96ca2aa756e4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import-contract.ts @@ -0,0 +1,32 @@ +import type { AdoptedTranscriptSource } from './adopted-transcript-source' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { NativeChatTurnLifecycle } from '../../../shared/native-chat-types' +import type { ResolveSessionFileOptions } from '../session-file-resolver' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import type { JournalPayloadLimits } from './journal-payload-bounds' +export type LegacyImportOptions = ResolveSessionFileOptions & { + filePath?: string + source?: AdoptedTranscriptSource + limits?: JournalPayloadLimits + decodedMessageIdentities?: true + signal?: AbortSignal +} + +export type LegacyImportResult = + | { + ok: true + epoch: string + cursor: AgentJournalCursor + imported: number + replaced: boolean + } + | { ok: false; error: string } + +export type PreparedLegacyImport = + | { + ok: true + items: JournalReplacementItem[] + sourceEmpty: boolean + latestTurn?: NativeChatTurnLifecycle + } + | { ok: false; error: string } diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts index 6142c8f41c3a..54cbd78bf640 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import-record-bound.test.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -39,3 +39,49 @@ describe.each(['claude', 'codex'] as const)('record byte bound: %s', (agent) => expect(vi.mocked(createReadStream).mock.results[0]?.value.destroyed).toBe(true) }) }) + +it('streams a valid source past 128 MiB, retaining recent bounded history and closing its readers', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-legacy-import-long-')) + roots.push(root) + const filePath = join(root, 'long.jsonl') + const file = await open(filePath, 'w') + try { + const output = 'x'.repeat(1024 * 1024) + for (let index = 0; index < 130; index++) { + await file.write( + `${JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: String(index), output } + })}\n` + ) + } + await file.write( + `${JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'recent answer' }] + } + })}\n` + ) + } finally { + await file.close() + } + const result = await prepareLegacyTranscriptImport({ + agent: 'codex', + sessionId: 'long', + options: { filePath } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.items.at(-1)?.body).toMatchObject({ blocks: [{ text: 'recent answer' }] }) + expect( + result.items.reduce((total, item) => total + Buffer.byteLength(JSON.stringify(item)), 0) + ).toBeLessThanOrEqual(RECORD_LIMIT_BYTES) + expect( + vi.mocked(createReadStream).mock.results.every(({ value }) => value.destroyed && value.closed) + ).toBe(true) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 88805af9b955..0e3558fcb318 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -1,67 +1,58 @@ -// Build a fresh epoch: import-scoped legacy identities cannot safely share structured sequence space. - +import type { + LegacyImportOptions, + LegacyImportResult, + PreparedLegacyImport +} from './journal-legacy-import-contract' +import { + LEGACY_IMPORT_BLOCK_LIMITS, + replacementItem, + replacementItems +} from './journal-legacy-import-body' import { adoptedTranscriptSourceObserver, validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' import type { AgentType } from '../../../shared/agent-status-types' -import type { - AgentJournalCursor, - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' -import { resolveSessionFilePath, type ResolveSessionFileOptions } from '../session-file-resolver' +import { resolveSessionFilePath } from '../session-file-resolver' import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, decodeOmpTranscriptLine } from '../transcript-line-decoders' +import { + decodePiTurnLifecycle, + nativeChatTurnLifecycleDecoderForAgent +} from '../transcript-turn-lifecycle' import { readLegacyImportTranscript } from './journal-legacy-transcript-stream' import { readOpenCodeNativeChatTranscriptFull } from '../transcript-opencode' -import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' import { createPiTranscriptCollector } from './journal-pi-active-branch' +import { readGrokTurnLifecycle } from '../transcript-grok-turn-lifecycle' import { createNewestItemWindow } from './journal-newest-item-window' import type { JournalReplacementItem } from './journal-epoch-replacement' -import { - boundInlineText, - boundToolInput, - type JournalPayloadLimits -} from './journal-payload-bounds' +import type { JournalPayloadLimits } from './journal-payload-bounds' import type { AgentSessionJournal } from './journal-store' +export { + type LegacyImportOptions, + type LegacyImportResult, + type PreparedLegacyImport +} from './journal-legacy-import-contract' -export const LEGACY_IMPORT_BLOCK_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } - -export type LegacyImportOptions = ResolveSessionFileOptions & { - filePath?: string - source?: AdoptedTranscriptSource - limits?: JournalPayloadLimits - decodedMessageIdentities?: true - signal?: AbortSignal -} +export { + LEGACY_IMPORT_BLOCK_LIMITS, + legacyItemBody, + boundBlock +} from './journal-legacy-import-body' +// Build a fresh epoch: import-scoped legacy identities cannot safely share structured sequence space. const MAX_LEGACY_IMPORT_RECORD_BYTES = 16 * 1024 * 1024 // Keep only recent history in memory; the provider resumes the complete source transcript. const MAX_LEGACY_IMPORT_RETAINED_BYTES = 16 * 1024 * 1024 -const MAX_LEGACY_IMPORT_SUBAGENTS = 64 - -export type LegacyImportResult = - | { - ok: true - epoch: string - cursor: AgentJournalCursor - imported: number - replaced: boolean - } - | { ok: false; error: string } - -type PreparedLegacyImport = - | { ok: true; items: JournalReplacementItem[]; sourceEmpty: boolean } - | { ok: false; error: string } export async function importLegacyTranscriptIntoJournal(input: { journal: AgentSessionJournal @@ -84,6 +75,7 @@ export async function importLegacyTranscriptIntoJournal(input: { export async function prepareLegacyTranscriptImport(input: { agent: AgentType + piAfterEntry?: string sessionId: string options?: LegacyImportOptions }): Promise { @@ -105,8 +97,9 @@ export async function prepareLegacyTranscriptImport(input: { } try { - const { items, consumedBytes } = await decodeReplacementItems({ + const { items, consumedBytes, latestTurn } = await decodeReplacementItems({ filePath, + piAfterEntry: input.piAfterEntry, transcriptAgent, agent: input.agent, sessionId: input.sessionId, @@ -118,7 +111,12 @@ export async function prepareLegacyTranscriptImport(input: { decodedMessageIdentities: options.decodedMessageIdentities, limits }) - return { ok: true, items, sourceEmpty: consumedBytes === 0 } + return { + ok: true, + items, + sourceEmpty: consumedBytes === 0, + ...(latestTurn ? { latestTurn } : {}) + } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } @@ -156,35 +154,11 @@ async function prepareOpenCodeImport(input: { recordId: message.id })) const items = replacementItems(read.messages, identities, input.limits) - return { ok: true, items, sourceEmpty: read.sourceEmpty === true } -} - -function replacementItems( - messages: readonly NativeChatMessage[], - identities: readonly AgentJournalItemIdentity[], - limits: JournalPayloadLimits -): JournalReplacementItem[] { - const replacement: JournalReplacementItem[] = [] - for (const [index, message] of messages.entries()) { - const identity = identities[index] - if (identity) { - replacement.push(replacementItem(message, identity, limits)) - } - } - return replacement -} - -function replacementItem( - message: NativeChatMessage, - identity: AgentJournalItemIdentity, - limits: JournalPayloadLimits -): JournalReplacementItem { return { - identity, - body: legacyItemBody(message, limits), - observedAt: message.timestamp ?? undefined, - // Imported history belongs to no active generation, even when saved rows claim to be running. - ownerFence: 0 + ok: true, + items, + sourceEmpty: read.sourceEmpty === true, + ...(read.lifecycle ? { latestTurn: read.lifecycle } : {}) } } @@ -199,6 +173,7 @@ const TRANSCRIPT_DECODERS = { /** Bound during decode so memory follows retained history rather than source-file size. */ async function decodeReplacementItems(input: { filePath: string + piAfterEntry?: string transcriptAgent: keyof typeof TRANSCRIPT_DECODERS agent: AgentType sessionId: string @@ -207,7 +182,11 @@ async function decodeReplacementItems(input: { codexSessionsDirs?: string[] signal?: AbortSignal limits: JournalPayloadLimits -}): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { +}): Promise<{ + items: JournalReplacementItem[] + consumedBytes: number + latestTurn?: NativeChatTurnLifecycle +}> { const source = input.source ? adoptedTranscriptSourceObserver({ agent: input.agent, @@ -220,14 +199,30 @@ async function decodeReplacementItems(input: { const tracker = createLegacyIdentityTracker({ transcriptAgent: input.transcriptAgent, agent: input.agent, - sessionId: input.sessionId + sessionId: input.agent === 'pi' ? input.filePath : input.sessionId }) const toItem = (message: NativeChatMessage, identity: AgentJournalItemIdentity) => replacementItem(message, identity, input.limits) const retained = createNewestItemWindow(MAX_LEGACY_IMPORT_RETAINED_BYTES) + const lifecycle = + input.agent === 'pi' + ? decodePiTurnLifecycle + : nativeChatTurnLifecycleDecoderForAgent(input.agent, true) + let latestTurn: NativeChatTurnLifecycle | undefined + const observe = (line: string, fallbackId: string) => { + const next = lifecycle?.(line, fallbackId) + if (next) { + latestTurn = + next.state === 'working' || !latestTurn ? next : { ...next, turnId: latestTurn.turnId } + } + } const pi = input.agent === 'pi' - ? createPiTranscriptCollector(toItem, (line, lineIndex) => tracker.identify(line, lineIndex)) + ? createPiTranscriptCollector( + toItem, + (line, lineIndex) => tracker.identify(line, lineIndex), + observe + ) : null let lineIndex = 0 try { @@ -240,8 +235,12 @@ async function decodeReplacementItems(input: { (line, fallbackId, recordId, selected = true) => { if (selected) { source?.observe(line) + if (!pi) { + observe(line, fallbackId) + } } - const currentLineIndex = lineIndex++ + const currentLineIndex = lineIndex + lineIndex += 1 if (pi) { pi.record(line, fallbackId, currentLineIndex) return null @@ -269,57 +268,27 @@ async function decodeReplacementItems(input: { await source?.validate() } if (pi) { - await pi.project(retained.push) + await pi.project(retained.push, input.piAfterEntry) } - return { items: retained.items(), consumedBytes } + if (input.agent === 'grok') { + latestTurn = await readGrokTurnLifecycle(input.filePath, input.sessionId) + const user = retained + .items() + .findLast((item) => item.body.kind === 'message' && item.body.role === 'user') + if ( + latestTurn && + latestTurn.state !== 'working' && + user?.observedAt != null && + latestTurn.timestamp != null && + latestTurn.timestamp < user.observedAt + ) { + latestTurn = undefined + } + } + return { items: retained.items(), consumedBytes, ...(latestTurn ? { latestTurn } : {}) } } finally { pi?.close() } } -export function legacyItemBody( - message: NativeChatMessage, - limits?: JournalPayloadLimits -): AgentJournalItemBody { - const only = message.blocks.length === 1 ? message.blocks[0] : undefined - if (only?.type === 'tool-call') { - // Bound adoption before epoch publication; catch-up uses whole-batch admission. - return { - kind: 'tool-call', - name: only.name, - input: limits ? boundToolInput(only.input, limits) : only.input, - ...(only.callId !== undefined ? { callId: only.callId } : {}), - state: 'completed' - } - } - return { - kind: 'message', - role: message.role, - blocks: limits ? message.blocks.map((block) => boundBlock(block, limits)) : message.blocks - } -} - /** Bound every bulk-bearing block, including mixed-message tool calls; the transcript retains full content. */ -function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock { - if (block.type === 'text') { - return { ...block, text: boundInlineText(block.text, limits).text } - } - if (block.type === 'tool-result') { - return { ...block, output: boundInlineText(block.output, limits).text } - } - if (block.type === 'tool-call') { - return { ...block, input: boundToolInput(block.input, limits) } - } - if (block.type === 'subagent-group') { - return { - ...block, - agents: block.agents.slice(0, MAX_LEGACY_IMPORT_SUBAGENTS).map((agent) => ({ - ...agent, - // Digest-based roster IDs keep children with shared prefixes distinct. - id: boundSubagentEntryId(agent.id), - label: boundInlineText(agent.label, limits).text - })) - } - } - return block -} diff --git a/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts b/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts index 37b30d3f1b9b..e41989c7308c 100644 --- a/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts +++ b/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts @@ -16,10 +16,11 @@ function closePiImportDatabase(db: Database.Database | null): void { // Spool the full tree until the saved leaf identifies the active branch. export function createPiTranscriptCollector( toItem: (message: NativeChatMessage, identity: AgentJournalItemIdentity) => T, - identify: (line: string, lineIndex: number) => AgentJournalItemIdentity + identify: (line: string, lineIndex: number) => AgentJournalItemIdentity, + observeSelected?: (line: string, fallbackId: string) => void ): { record(line: string, fallbackId: string, lineIndex: number): void - project(keep: (item: T) => void): Promise + project(keep: (item: T) => void, afterEntry?: string): Promise close(): void } { let db: Database.Database @@ -113,7 +114,7 @@ export function createPiTranscriptCollector( db.exec('COMMIT; BEGIN') } }, - async project(keep) { + async project(keep, afterEntry) { db.exec('COMMIT') if (leafId === null) { return @@ -147,6 +148,52 @@ export function createPiTranscriptCollector( } } db.exec('COMMIT') + if (observeSelected) { + let processedRows = 0 + let processedBytes = 0 + let sliceStartedAt = performance.now() + for (const row of db + .prepare( + 'SELECT raw_line, fallback_id FROM entries WHERE branch_depth IS NOT NULL ORDER BY branch_depth DESC' + ) + .iterate()) { + if (typeof row.raw_line === 'string' && typeof row.fallback_id === 'string') { + observeSelected(row.raw_line, row.fallback_id) + processedRows += 1 + processedBytes += Buffer.byteLength(row.raw_line, 'utf8') + if ( + processedBytes >= 1024 * 1024 || + performance.now() - sliceStartedAt >= 8 || + processedRows >= 256 + ) { + await new Promise((resolve) => setImmediate(resolve)) + processedRows = 0 + processedBytes = 0 + sliceStartedAt = performance.now() + } + } + } + } + let throughDepth = depth - 1 + if (afterEntry) { + const visited = new Set() + let boundary: string | null = afterEntry + for (;;) { + if (!boundary || visited.has(boundary)) { + throw new Error('Pi history claim is not in its tree') + } + visited.add(boundary) + const row = byId.get(boundary) + if (!row) { + throw new Error('Pi history claim is not in its tree') + } + if (typeof row.branch_depth === 'number') { + throughDepth = row.branch_depth - 1 + break + } + boundary = typeof row.parent_id === 'string' ? row.parent_id : null + } + } const selected = db.prepare(`SELECT raw_line, fallback_id, line_index FROM entries WHERE branch_depth IS NOT NULL AND branch_depth >= ? AND branch_depth <= ? ORDER BY branch_depth DESC`) @@ -154,7 +201,7 @@ export function createPiTranscriptCollector( let processedRows = 0 let processedBytes = 0 let sliceStartedAt = performance.now() - for (const row of selected.iterate(from, through)) { + for (const row of selected.iterate(from, Math.min(through, throughDepth))) { if ( typeof row.raw_line !== 'string' || typeof row.fallback_id !== 'string' || diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/claude-cli-switch-2.1.302.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/claude-cli-switch-2.1.302.jsonl new file mode 100644 index 000000000000..2b6802a71956 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/claude-cli-switch-2.1.302.jsonl @@ -0,0 +1,4 @@ +{"type":"user","uuid":"124e6d1a-016e-46a5-8113-6a893a97b769","parentUuid":null,"sessionId":"402eff2b-84dc-4e05-8fe8-49fffb2cd3c3","timestamp":"2026-10-21T18:28:43.188Z","version":"2.1.302","message":{"role":"user","content":"Think briefly, then read notes.txt with the Read tool, then answer in two short paragraphs what it says."}} +{"type":"assistant","uuid":"ec769311-5ca1-4473-9aef-6eb98c796aea","parentUuid":"b9d356ff-ca87-4328-963f-6d7f37e3b3a2","sessionId":"402eff2b-84dc-4e05-8fe8-49fffb2cd3c3","timestamp":"2026-10-21T18:28:47.931Z","version":"2.1.302","message":{"model":"claude-opus-5-5","id":"msg_011CgFrcPqGdULP5eSXarKBB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JdDiY9LSSGER1aErtVjSW3","name":"Read","input":{"file_path":"/workspace/notes.txt"},"caller":{"type":"direct"}}],"container":null,"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":34604,"cache_read_input_tokens":0,"output_tokens":90,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":34604},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null}} +{"type":"user","uuid":"0b87375c-fa56-4da1-813d-82e9543630bf","parentUuid":"ec769311-5ca1-4473-9aef-6eb98c796aea","sessionId":"402eff2b-84dc-4e05-8fe8-49fffb2cd3c3","timestamp":"2026-10-21T18:28:47.959Z","version":"2.1.302","message":{"role":"user","content":[{"tool_use_id":"toolu_01JdDiY9LSSGER1aErtVjSW3","type":"tool_result","content":"1\thello\n2\t"}]}} +{"type":"assistant","uuid":"0751a3af-972a-47fb-b4b4-aebfdc7ada82","parentUuid":"605a2f23-e76a-489a-a57c-03471e5d54e8","sessionId":"402eff2b-84dc-4e05-8fe8-49fffb2cd3c3","timestamp":"2026-10-21T18:28:58.471Z","version":"2.1.302","message":{"model":"claude-opus-5-5","id":"msg_011CgFrdCBJf5xTJsM8xiacb","type":"message","role":"assistant","content":[{"type":"text","text":"NOTHING NEEDED: I read notes.txt and nothing is running.\n\n`notes.txt` holds one line, the word \"hello\", followed by a newline.\n\nThat's all it says. There are no instructions, notes about the lane or anything else to act on."}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":34604,"output_tokens":81,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":126},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null}} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/completed.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/completed.jsonl new file mode 100644 index 000000000000..dc08f7a2087a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/completed.jsonl @@ -0,0 +1,17 @@ +{"timestamp":"2026-10-28T18:52:57.513Z","ordinal":0,"type":"session_meta","payload":{"session_id":"[redacted]","id":"01a183db-b8d6-7170-af39-e004651c1e1e","timestamp":"2026-10-28T18:52:57.454Z","cwd":"/qa-owned/scratch","runtime_workspace_roots":["[redacted]"],"originator":"codex_exec","cli_version":"0.162.1","source":"exec","thread_source":"[redacted]","model_provider":"existing_lb","base_instructions":{"text":"[redacted]","provenance":{"type":"model","model":"gpt-6.1-sol"}},"history_mode":"paginated","context_window":{"window_id":"[redacted]"},"git":{"commit_hash":"[redacted]","branch":"[redacted]"}}} +{"timestamp":"2026-10-28T20:31:39.367Z","ordinal":424,"type":"event_msg","payload":{"type":"task_started","turn_attribution":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","turn_trigger":"[redacted]","parent_turn_id":null,"initiating_agent_path":null,"root_turn_id":"[redacted]"},"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","root_turn_id":"[redacted]","started_at":1793219499,"model_context_window":258400,"collaboration_mode_kind":"[redacted]"}} +{"timestamp":"2026-10-28T20:31:39.369Z","ordinal":425,"type":"turn_context","payload":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","root_turn_id":"[redacted]","disabled_plugin_ids":[],"cwd":"/qa-owned/scratch","approval_policy":"[redacted]","approvals_reviewer":"[redacted]","sandbox_policy":{"type":"danger-full-access"},"permission_profile":{"type":"disabled"},"model":"gpt-6.1-sol","comp_hash":"[redacted]","collaboration_mode":{"mode":"[redacted]","settings":{"model":"gpt-6.1-sol","reasoning_effort":"high","developer_instructions":null}},"multi_agent_version":"[redacted]","realtime_active":false,"effort":"high","summary":"[redacted]"}} +{"timestamp":"2026-10-28T20:31:39.371Z","ordinal":426,"type":"response_item","payload":{"type":"message","id":"msg_01a18436-156b-7201-99c4-d4f6fd74424c","role":"user","content":[{"type":"input_text","text":"[redacted]"}],"internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219499.371831,"content_item_kinds":["[redacted]"]}},"metadata":{"retained_source":{"id":{"message_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","role":"user"},"revision":"[redacted]","complete":true},"client_authored":false,"user_input_order":67,"mcp_attribution":{"status":"none"}}} +{"timestamp":"2026-10-28T20:31:39.372Z","ordinal":427,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"UserMessage","id":"01a18436-156b-7201-99c4-d506a478fc0d","content":[{"type":"text","text":"[redacted]","text_elements":[]}]},"started_at_ms":1793219499372,"completed_at_ms":1793219499372}} +{"timestamp":"2026-10-28T20:32:19.591Z","ordinal":428,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"AgentMessage","id":"msg_0c86421a39dd238c016ae25bd2c5f48199961b2ddbe0a5fd13","content":[{"type":"Text","text":"[redacted]"}],"phase":"commentary"},"started_at_ms":1793219539591,"completed_at_ms":1793219539591}} +{"timestamp":"2026-10-28T20:32:19.594Z","ordinal":429,"type":"response_item","payload":{"type":"message","id":"msg_0c86421a39dd238c016ae25bd2c5f48199961b2ddbe0a5fd13","role":"assistant","content":[{"type":"output_text","text":"[redacted]"}],"phase":"commentary","internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219538.782227,"content_item_kinds":["[redacted]"]}},"metadata":{"retained_source":{"id":{"message_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","role":"assistant"},"revision":"[redacted]","complete":true},"client_authored":false,"user_input_order":68}} +{"timestamp":"2026-10-28T20:32:23.372Z","ordinal":430,"type":"response_item","payload":{"type":"custom_tool_call","id":"ctc_0c86421a39dd238c016ae25bd310848199973af0c0b9614a3b","status":"completed","call_id":"call_7aa9bb3a03fc4709a96ba91c3200a189","name":"exec","input":"[redacted]","internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219539.0680492}}} +{"timestamp":"2026-10-28T20:32:23.476Z","ordinal":431,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"CommandExecution","id":"exec-a23370e1-7bc9-4b2e-af2e-ffd99e3419dd","process_id":"[redacted]","command":["[redacted]","[redacted]","[redacted]"],"cwd":"/qa-owned/scratch","parsed_cmd":[{"type":"unknown","cmd":"[redacted]"}],"source":"unified_exec_startup","status":"completed","aggregated_output":"[redacted]","exit_code":0,"duration":{"secs":0,"nanos":3959}},"started_at_ms":1793219543476,"completed_at_ms":1793219543476}} +{"timestamp":"2026-10-28T20:32:23.599Z","ordinal":432,"type":"token_usage_record","payload":{"thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","session_id":"[redacted]","root_turn_id":"[redacted]","response_id":"[redacted]","usage":{"input_tokens":30860,"cached_input_tokens":30464,"cache_write_input_tokens":0,"output_tokens":441,"reasoning_output_tokens":0,"total_tokens":31301},"turn_token_usage":{"input_tokens":30860,"cached_input_tokens":30464,"cache_write_input_tokens":0,"output_tokens":441,"reasoning_output_tokens":0,"total_tokens":31301},"thread_token_usage":{"input_tokens":1048727,"cached_input_tokens":968320,"cache_write_input_tokens":0,"output_tokens":10386,"reasoning_output_tokens":197,"total_tokens":1059113}}} +{"timestamp":"2026-10-28T20:32:23.599Z","ordinal":433,"type":"response_item","payload":{"type":"custom_tool_call_output","id":"ctco_01a18436-c22f-70a3-8d5b-fae16bd363df","call_id":"call_7aa9bb3a03fc4709a96ba91c3200a189","output":[{"type":"input_text","text":"[redacted]"},{"type":"input_text","text":"[redacted]"}],"internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219543.599943}},"metadata":{"client_authored":false,"fallback_token_limit_override":12000}} +{"timestamp":"2026-10-28T20:32:23.600Z","ordinal":434,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1048727,"cached_input_tokens":968320,"cache_write_input_tokens":0,"output_tokens":10386,"reasoning_output_tokens":197,"total_tokens":1059113},"last_token_usage":{"input_tokens":30860,"cached_input_tokens":30464,"cache_write_input_tokens":0,"output_tokens":441,"reasoning_output_tokens":0,"total_tokens":31301},"model_context_window":258400},"rate_limits":{"limit_id":"[redacted]","limit_name":null,"primary":null,"secondary":{"used_percent":44.57142857142857,"window_minutes":10080,"resets_at":1793774136},"credits":{"has_credits":true,"unlimited":false,"balance":"[redacted]"},"individual_limit":null,"spend_control_reached":null,"plan_type":null,"rate_limit_reached_type":null}}} +{"timestamp":"2026-10-28T20:33:00.077Z","ordinal":435,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"AgentMessage","id":"msg_0c86421a39dd238c016ae25bfa61e0819983333e6ccd7e20fd","content":[{"type":"Text","text":"[redacted]"}],"phase":"final_answer"},"started_at_ms":1793219578648,"completed_at_ms":1793219580077}} +{"timestamp":"2026-10-28T20:33:00.081Z","ordinal":436,"type":"response_item","payload":{"type":"message","id":"msg_0c86421a39dd238c016ae25bfa61e0819983333e6ccd7e20fd","role":"assistant","content":[{"type":"output_text","text":"[redacted]"}],"phase":"final_answer","internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219578.409068,"content_item_kinds":["[redacted]"]}},"metadata":{"retained_source":{"id":{"message_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","role":"assistant"},"revision":"[redacted]","complete":true},"client_authored":false,"user_input_order":69}} +{"timestamp":"2026-10-28T20:33:00.152Z","ordinal":437,"type":"token_usage_record","payload":{"thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","session_id":"[redacted]","root_turn_id":"[redacted]","response_id":"[redacted]","usage":{"input_tokens":32026,"cached_input_tokens":31104,"cache_write_input_tokens":0,"output_tokens":75,"reasoning_output_tokens":0,"total_tokens":32101},"turn_token_usage":{"input_tokens":62886,"cached_input_tokens":61568,"cache_write_input_tokens":0,"output_tokens":516,"reasoning_output_tokens":0,"total_tokens":63402},"thread_token_usage":{"input_tokens":1080753,"cached_input_tokens":999424,"cache_write_input_tokens":0,"output_tokens":10461,"reasoning_output_tokens":197,"total_tokens":1091214}}} +{"timestamp":"2026-10-28T20:33:00.152Z","ordinal":438,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1080753,"cached_input_tokens":999424,"cache_write_input_tokens":0,"output_tokens":10461,"reasoning_output_tokens":197,"total_tokens":1091214},"last_token_usage":{"input_tokens":32026,"cached_input_tokens":31104,"cache_write_input_tokens":0,"output_tokens":75,"reasoning_output_tokens":0,"total_tokens":32101},"model_context_window":258400},"rate_limits":{"limit_id":"[redacted]","limit_name":null,"primary":null,"secondary":{"used_percent":44.57142857142857,"window_minutes":10080,"resets_at":1793774136},"credits":{"has_credits":true,"unlimited":false,"balance":"[redacted]"},"individual_limit":null,"spend_control_reached":null,"plan_type":null,"rate_limit_reached_type":null}}} +{"timestamp":"2026-10-28T20:33:00.155Z","ordinal":439,"type":"event_msg","payload":{"type":"task_complete","root_turn_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","last_agent_message":"[redacted]","started_at":1793219499,"completed_at":1793219580,"duration_ms":80787,"time_to_first_token_ms":40224}} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/recorded-prefix.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/recorded-prefix.jsonl new file mode 100644 index 000000000000..08850a92fe21 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/codex/recorded-prefix.jsonl @@ -0,0 +1,11 @@ +{"timestamp":"2026-10-28T18:52:57.513Z","ordinal":0,"type":"session_meta","payload":{"session_id":"[redacted]","id":"01a183db-b8d6-7170-af39-e004651c1e1e","timestamp":"2026-10-28T18:52:57.454Z","cwd":"/qa-owned/scratch","runtime_workspace_roots":["[redacted]"],"originator":"codex_exec","cli_version":"0.162.1","source":"exec","thread_source":"[redacted]","model_provider":"existing_lb","base_instructions":{"text":"[redacted]","provenance":{"type":"model","model":"gpt-6.1-sol"}},"history_mode":"paginated","context_window":{"window_id":"[redacted]"},"git":{"commit_hash":"[redacted]","branch":"[redacted]"}}} +{"timestamp":"2026-10-28T20:31:39.367Z","ordinal":424,"type":"event_msg","payload":{"type":"task_started","turn_attribution":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","turn_trigger":"[redacted]","parent_turn_id":null,"initiating_agent_path":null,"root_turn_id":"[redacted]"},"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","root_turn_id":"[redacted]","started_at":1793219499,"model_context_window":258400,"collaboration_mode_kind":"[redacted]"}} +{"timestamp":"2026-10-28T20:31:39.369Z","ordinal":425,"type":"turn_context","payload":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","root_turn_id":"[redacted]","disabled_plugin_ids":[],"cwd":"/qa-owned/scratch","approval_policy":"[redacted]","approvals_reviewer":"[redacted]","sandbox_policy":{"type":"danger-full-access"},"permission_profile":{"type":"disabled"},"model":"gpt-6.1-sol","comp_hash":"[redacted]","collaboration_mode":{"mode":"[redacted]","settings":{"model":"gpt-6.1-sol","reasoning_effort":"high","developer_instructions":null}},"multi_agent_version":"[redacted]","realtime_active":false,"effort":"high","summary":"[redacted]"}} +{"timestamp":"2026-10-28T20:31:39.371Z","ordinal":426,"type":"response_item","payload":{"type":"message","id":"msg_01a18436-156b-7201-99c4-d4f6fd74424c","role":"user","content":[{"type":"input_text","text":"[redacted]"}],"internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219499.371831,"content_item_kinds":["[redacted]"]}},"metadata":{"retained_source":{"id":{"message_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","role":"user"},"revision":"[redacted]","complete":true},"client_authored":false,"user_input_order":67,"mcp_attribution":{"status":"none"}}} +{"timestamp":"2026-10-28T20:31:39.372Z","ordinal":427,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"UserMessage","id":"01a18436-156b-7201-99c4-d506a478fc0d","content":[{"type":"text","text":"[redacted]","text_elements":[]}]},"started_at_ms":1793219499372,"completed_at_ms":1793219499372}} +{"timestamp":"2026-10-28T20:32:19.591Z","ordinal":428,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"AgentMessage","id":"msg_0c86421a39dd238c016ae25bd2c5f48199961b2ddbe0a5fd13","content":[{"type":"Text","text":"[redacted]"}],"phase":"commentary"},"started_at_ms":1793219539591,"completed_at_ms":1793219539591}} +{"timestamp":"2026-10-28T20:32:19.594Z","ordinal":429,"type":"response_item","payload":{"type":"message","id":"msg_0c86421a39dd238c016ae25bd2c5f48199961b2ddbe0a5fd13","role":"assistant","content":[{"type":"output_text","text":"[redacted]"}],"phase":"commentary","internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219538.782227,"content_item_kinds":["[redacted]"]}},"metadata":{"retained_source":{"id":{"message_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","role":"assistant"},"revision":"[redacted]","complete":true},"client_authored":false,"user_input_order":68}} +{"timestamp":"2026-10-28T20:32:23.372Z","ordinal":430,"type":"response_item","payload":{"type":"custom_tool_call","id":"ctc_0c86421a39dd238c016ae25bd310848199973af0c0b9614a3b","status":"completed","call_id":"call_7aa9bb3a03fc4709a96ba91c3200a189","name":"exec","input":"[redacted]","internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219539.0680492}}} +{"timestamp":"2026-10-28T20:32:23.476Z","ordinal":431,"type":"event_msg","payload":{"type":"item_completed","thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","item":{"type":"CommandExecution","id":"exec-a23370e1-7bc9-4b2e-af2e-ffd99e3419dd","process_id":"[redacted]","command":["[redacted]","[redacted]","[redacted]"],"cwd":"/qa-owned/scratch","parsed_cmd":[{"type":"unknown","cmd":"[redacted]"}],"source":"unified_exec_startup","status":"completed","aggregated_output":"[redacted]","exit_code":0,"duration":{"secs":0,"nanos":3959}},"started_at_ms":1793219543476,"completed_at_ms":1793219543476}} +{"timestamp":"2026-10-28T20:32:23.599Z","ordinal":432,"type":"token_usage_record","payload":{"thread_id":"[redacted]","turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","session_id":"[redacted]","root_turn_id":"[redacted]","response_id":"[redacted]","usage":{"input_tokens":30860,"cached_input_tokens":30464,"cache_write_input_tokens":0,"output_tokens":441,"reasoning_output_tokens":0,"total_tokens":31301},"turn_token_usage":{"input_tokens":30860,"cached_input_tokens":30464,"cache_write_input_tokens":0,"output_tokens":441,"reasoning_output_tokens":0,"total_tokens":31301},"thread_token_usage":{"input_tokens":1048727,"cached_input_tokens":968320,"cache_write_input_tokens":0,"output_tokens":10386,"reasoning_output_tokens":197,"total_tokens":1059113}}} +{"timestamp":"2026-10-28T20:32:23.599Z","ordinal":433,"type":"response_item","payload":{"type":"custom_tool_call_output","id":"ctco_01a18436-c22f-70a3-8d5b-fae16bd363df","call_id":"call_7aa9bb3a03fc4709a96ba91c3200a189","output":[{"type":"input_text","text":"[redacted]"},{"type":"input_text","text":"[redacted]"}],"internal_chat_message_metadata_passthrough":{"turn_id":"01a18436-1564-7692-8f31-ebe85b09f74f","create_time":1793219543.599943}},"metadata":{"client_authored":false,"fallback_token_limit_override":12000}} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/chat_history.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/chat_history.jsonl new file mode 100644 index 000000000000..bbd7c9ecf0a2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/chat_history.jsonl @@ -0,0 +1,5 @@ +{"type":"user","content":[{"type":"text","text":"[redacted]"}]} +{"type":"assistant","content":"[redacted]","tool_calls":[{"id":"call-7acd954c-193d-404b-9131-abefbcf73701-0","name":"read_file","arguments":"{\"target_file\":\"[redacted]\"}"},{"id":"call-7acd954c-193d-404b-9131-abefbcf73701-1","name":"read_file","arguments":"{\"target_file\":\"[redacted]\"}"}],"model_id":"grok-4.7-build","model_fingerprint":"fp_5719f1aec35dd73c","reasoning_effort":"high"} +{"type":"tool_result","tool_call_id":"call-7acd954c-193d-404b-9131-abefbcf73701-0","content":"[redacted]"} +{"type":"tool_result","tool_call_id":"call-7acd954c-193d-404b-9131-abefbcf73701-1","content":"[redacted]"} +{"type":"assistant","content":"[redacted]","model_id":"grok-4.7-build","model_fingerprint":"fp_5719f1aec35dd73c","reasoning_effort":"high"} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/events.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/events.jsonl new file mode 100644 index 000000000000..51cda866d106 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/completed/events.jsonl @@ -0,0 +1,2 @@ +{"ts":"2026-10-04T07:40:39.234Z","type":"turn_started","session_id":"01a105db-937d-7ec0-b1d1-5396ae618abf","turn_number":0,"model_id":"grok-4.7","yolo_mode":true,"conversation_message_count":3,"schema_version":"1.0"} +{"ts":"2026-10-04T07:54:24.927Z","type":"turn_ended","outcome":"completed"} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/chat_history.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/chat_history.jsonl new file mode 100644 index 000000000000..a42a7b9802a3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/chat_history.jsonl @@ -0,0 +1,2 @@ +{"type":"user","content":[{"type":"text","text":"[redacted]"}]} +{"type":"assistant","content":"[redacted]","tool_calls":[{"id":"call-7acd954c-193d-404b-9131-abefbcf73701-0","name":"read_file","arguments":"{\"target_file\":\"[redacted]\"}"},{"id":"call-7acd954c-193d-404b-9131-abefbcf73701-1","name":"read_file","arguments":"{\"target_file\":\"[redacted]\"}"}],"model_id":"grok-4.7-build","model_fingerprint":"fp_5719f1aec35dd73c","reasoning_effort":"high"} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/events.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/events.jsonl new file mode 100644 index 000000000000..2e60b5a167f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/grok/interrupted-prefix/events.jsonl @@ -0,0 +1 @@ +{"ts":"2026-10-04T07:40:39.234Z","type":"turn_started","session_id":"01a105db-937d-7ec0-b1d1-5396ae618abf","turn_number":0,"model_id":"grok-4.7","yolo_mode":true,"conversation_message_count":3,"schema_version":"1.0"} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/completed.db b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/completed.db new file mode 100644 index 000000000000..870602a71288 Binary files /dev/null and b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/completed.db differ diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/recorded-prefix.db b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/recorded-prefix.db new file mode 100644 index 000000000000..d065fc08d18c Binary files /dev/null and b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode/recorded-prefix.db differ diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/completed.db b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/completed.db new file mode 100644 index 000000000000..4279107e21d2 Binary files /dev/null and b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/completed.db differ diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/recorded-prefix.db b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/recorded-prefix.db new file mode 100644 index 000000000000..7bda539be649 Binary files /dev/null and b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/opencode2/recorded-prefix.db differ diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/pi/completed.jsonl b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/pi/completed.jsonl new file mode 100644 index 000000000000..adec39cb0665 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/pi/completed.jsonl @@ -0,0 +1,7 @@ +{"type":"session","version":3,"id":"01a1841b-2daa-7238-ade8-4b2474dcac14","timestamp":"2026-10-28T20:02:16.106Z","cwd":"C:\\qa\\g31595qg\\scratch"} +{"type":"model_change","id":"d254652a","parentId":null,"timestamp":"2026-10-28T20:02:16.130Z","provider":"openai-codex","modelId":"gpt-5.6-sol"} +{"type":"thinking_level_change","id":"a2eb46ce","parentId":"d254652a","timestamp":"2026-10-28T20:02:16.130Z","thinkingLevel":"high"} +{"type":"message","id":"53b89244","parentId":"a2eb46ce","timestamp":"2026-10-28T20:02:16.135Z","message":{"role":"user","content":[{"type":"text","text":"Use the read tool to read ./sum.js, then explain the return value of score([2, 3, 5]). Read only that file. Do not change any files. Complete in one reply."}],"timestamp":1793217736134}} +{"type":"message","id":"f2248d5e","parentId":"53b89244","timestamp":"2026-10-28T20:02:17.582Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_120d5b1b968943569603ef84266f5b30|fc_02beebbc0507808c016ae254cc653881968462199fde88d391","name":"read","arguments":{"path":"./sum.js"}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.6-sol","usage":{"input":608,"output":19,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":627,"cost":{"input":0.00304,"output":0.00057,"cacheRead":0,"cacheWrite":0,"total":0.0036100000000000004}},"stopReason":"toolUse","timestamp":1793217736143,"responseId":"resp_02beebbc0507808c016ae254cba01881968ab9d27bdfb10d09","rawStopReason":"completed"}} +{"type":"message","id":"6abe1199","parentId":"f2248d5e","timestamp":"2026-10-28T20:02:17.595Z","message":{"role":"toolResult","toolCallId":"call_120d5b1b968943569603ef84266f5b30|fc_02beebbc0507808c016ae254cc653881968462199fde88d391","toolName":"read","content":[{"type":"text","text":"export function score(values) {\n return values.reduce((total, value) => total + value * 2, 0);\n}\n"}],"isError":false,"timestamp":1793217737595}} +{"type":"message","id":"e7d94382","parentId":"6abe1199","timestamp":"2026-10-28T20:02:19.222Z","message":{"role":"assistant","content":[{"type":"text","text":"`score([2, 3, 5])` returns **20**.\n\nEach value is doubled and summed: `2×2 + 3×2 + 5×2 = 20`.","textSignature":"{\"v\":1,\"id\":\"msg_02beebbc0507808c016ae254cdaa6c8196a50ba2698b836f73\",\"phase\":\"final_answer\"}"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.6-sol","usage":{"input":664,"output":46,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":710,"cost":{"input":0.0033200000000000005,"output":0.00138,"cacheRead":0,"cacheWrite":0,"total":0.0047}},"stopReason":"stop","timestamp":1793217737595,"responseId":"resp_02beebbc0507808c016ae254ccd1ec8196a266c0f85bab35e8","rawStopReason":"completed"}} diff --git a/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/provenance.json b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/provenance.json new file mode 100644 index 000000000000..c5424c990659 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/__fixtures__/recorded-native-switch/provenance.json @@ -0,0 +1,101 @@ +{ + "purpose": "Recorded native storage importer contracts; no Stop, physical-exit, app QA or performance evidence. Prefixes are fixture truncations, never captured Stop outcomes.", + "files": [ + { + "path": "pi/completed.jsonl", + "sha256": "7d35c76440f43d76e14c6d5e90063c9c065c76d5d1c42167aef84ffc60fe8f84" + }, + { + "path": "codex/completed.jsonl", + "sha256": "66931dc8b21ee48b0e1b00a30e51a686843eecd8c56992372cb93089a93cfe7f" + }, + { + "path": "codex/recorded-prefix.jsonl", + "sha256": "20fc1985b2723e08f60d3aad3fa0b594686f047aee93e6a44f729a5f248a7df1" + }, + { + "path": "opencode2/completed.db", + "sha256": "6adff2c3503c63dddd9eddc44fc18ce45654bc0eca5ce271a493ce6375591692" + }, + { + "path": "opencode2/recorded-prefix.db", + "sha256": "c327cc7bdbccfea88194c06b62e46007fb1704726aa384b6010e64b163d5018b" + }, + { + "path": "grok/completed/chat_history.jsonl", + "sha256": "4a4249ad3118af75a6c15dd4074af8dc8625e759bcc35bdc1cc5ebae7584d72f" + }, + { + "path": "grok/completed/events.jsonl", + "sha256": "b9e466e7d2145627d73f225291da8904caa4f9c26db897cc5e0674e42d667a16" + }, + { + "path": "grok/interrupted-prefix/chat_history.jsonl", + "sha256": "91df4258a54ad9ccb2891f5a6efb2d3a5c481ac9b64fe109c7505aafce48558e" + }, + { + "path": "grok/interrupted-prefix/events.jsonl", + "sha256": "63f5a0033085f5ae31a2527e822883e65095bc15e66d70f549fce1615d7d25b0" + }, + { + "path": "opencode/completed.db", + "sha256": "94dae9fa553166f0753de273ace65aec7fed74ac87d32249360cc35671e1b64d" + }, + { + "path": "opencode/recorded-prefix.db", + "sha256": "aec91e63b5339a76639bd7082321722334e5cdbd31c83bc65567c21ef279b671" + } + ], + "agents": { + "pi": { + "provenanceReceipt": "pi-provenance.json", + "provenanceSha256": "55761da8a698d10dec3a035a1c4537bb82ebd9d9438612156200d792820d6efc", + "version": "0.84.2", + "sourceSha256": "7d35c76440f43d76e14c6d5e90063c9c065c76d5d1c42167aef84ffc60fe8f84", + "sanitization": "Safety checked; seven native session rows retained byte-for-byte; no event record converted or relabelled.", + "prefix": "First six of seven unchanged native session rows, generated by the test." + }, + "codex": { + "provenanceReceipt": "codex-provenance.json", + "provenanceSha256": "956f2b99a7870ac074d4760efd075bb7fa2ea82d141806015b8a0a1bfef0a7cf", + "version": "codex-cli 0.162.1", + "sourceSha256": "2f740185ee6c19a97572e532a74f8cb4b3bf3742d012777f78c849ed45635af9", + "sanitization": "all transcript/instructions/reasoning/tool argument/output/path strings recursively redacted; structural types, roles, IDs, timestamps, model metadata and native lifecycle retained", + "scope": "One genuine completed turn (17 native records) from partial22 capture; not a qualified40-turn benchmark." + }, + "opencode2": { + "provenanceReceipt": "opencode2-provenance.json", + "provenanceSha256": "58c144732793334f719fc8d78f00d1e73f65eea5c9660c40892ebf543d7ad2a3", + "version": "2.0.14", + "sourceSha256": "6123e6b120eb62f130692d0523ec5f325343f7955f3559491e42a5d00f735699", + "sanitization": "all text, reasoning/encrypted state, tool input/output, error bodies, snapshot/path/title strings redacted recursively; native types/IDs/sequences/timestamps/model/finish/status/outcome retained; output DB uses original native CREATE TABLE statements", + "schema": "Original native CREATE TABLE statements: session_v2 and session_message; no v1 representation or alias conversion." + }, + "grok": { + "provenanceReceipt": "grok-provenance.json", + "provenanceSha256": "62c67158a506942bbabfe374e211061d88718f8a2f4cfdfb969cda36784f23a5", + "version": "not persisted by this saved source; do not infer from installed version", + "sourceSha256": null, + "sanitization": "Content text recursively replaced, tool argument JSON leaves redacted; actual types/roles/tool names/IDs/lifecycle metadata preserved; no native records synthesized; sidecar is minimal actual turn_started/turn_ended selection with whitelisted lifecycle scalar fields only", + "sourceFiles": [ + { + "name": "chat_history.jsonl", + "sha256": "e18512f205cd220147c5075cf415aee25a4a11e46711ca2c27f9dce860bd4b88" + }, + { + "name": "events.jsonl", + "sha256": "9a8e0721c9a67a16f2d7b6ced8ad35457da062600fe8afd54f0440416de99282" + } + ] + }, + "opencode": { + "version": "1.19.5", + "provenanceReceipt": "opencode1-provenance.json", + "provenanceSha256": "69e0f03485173ceb90229a89d86984da2e9a335a5cf69e8f6543eef3c94fcc85", + "sourceSha256": "1798f746d34efe4d1bbd6bfa9695940f43bb78d94d8c9e2138ca0340517d7a41", + "sanitization": "native schema/IDs/order/model/finish/times/status retained; content/tool input/output/path/title redacted recursively", + "schema": "Actual v1 session/message/part; no v2 aliases or synthesized rows.", + "prefix": "fixture truncation after first completed real tool; not Stop/exit proof" + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 2ddef409a533..f946c28ee779 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -23,6 +23,41 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } +/** Exit continuations retain only completions, never the attach request or its environment. */ +function physicalAcquisition() { + const physical = Promise.withResolvers() + let acquiring = true + let observedOwner = false + let processless = false + let pendingOwners = 0 + const settle = (): void => { + if (!acquiring && pendingOwners === 0 && (observedOwner || processless)) { + physical.resolve() + } + } + return { + promise: physical.promise, + observe: (exited: Promise) => { + observedOwner = true + pendingOwners++ + void exited.then( + () => { + pendingOwners-- + settle() + }, + () => {} + ) + }, + processless: () => { + processless = true + }, + settled: () => { + acquiring = false + settle() + } + } +} + /** Spawn makes a reservation real; the store grants write authority afterward. */ export async function acquireOwner( input: AttachFlowInput, @@ -36,40 +71,57 @@ export async function acquireOwner( if (!spawnToken) { throw new Error('agent_session_ownership_unknown') } + const physical = physicalAcquisition() + input.onPhysicalExit?.(physical.promise) try { + let attempt: ReturnType + let progress: ReturnType> | undefined try { - await input.onAcquiring?.() - } catch (error) { - throw new AgentSessionPreSpawnError(error) - } - const permissionMode = await input.launchPermissionMode?.(record) - try { - await input.assertNoTerminalWriter?.(record) - } catch (error) { - // Not a code: a code message would travel as the census's own conflict, not this reason. - throw new AgentSessionPreSpawnError(error, { - reason: 'conversationOpenInTerminal', - message: 'a terminal may still be writing this conversation' + try { + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const permissionMode = await input.launchPermissionMode?.(record) + try { + await input.assertNoTerminalWriter?.(record) + } catch (error) { + // Not a code: a code message would travel as the census's own conflict, not this reason. + throw new AgentSessionPreSpawnError(error, { + reason: 'conversationOpenInTerminal', + message: 'a terminal may still be writing this conversation' + }) + } + // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. + if (input.acquireSignal?.aborted) { + throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) + } + try { + input.checkBeforeAcquire?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + attempt = mintStructuredAgentSessionStartupAttempt({ + record, + identity: journalIdentityFor(record, input.params), + // Retry the existing reservation instead of spawning a second child. + spawnToken, + ...(permissionMode ? { permissionMode } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}), + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}), + ...(input.startupAttemptId ? { attemptId: input.startupAttemptId } : {}) }) + progress = input.onStartupAttempt?.(attempt) + } catch (error) { + // Adapter entry has not happened; preserve the error's ordinary cleanup classification. + physical.processless() + throw error } - // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. - if (input.acquireSignal?.aborted) { - throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) - } - const attempt = mintStructuredAgentSessionStartupAttempt({ - record, - identity: journalIdentityFor(record, input.params), - // Retry the existing reservation instead of spawning a second child. - spawnToken, - ...(permissionMode ? { permissionMode } : {}), - ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}), - ...(input.startupAttemptId ? { attemptId: input.startupAttemptId } : {}) - }) - const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ ...attempt, + onPhysicalExit: physical.observe, + checkBeforeSpawn: input.checkBeforeAcquire, ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), onSpawned: async (process) => { progress?.spawned() @@ -91,17 +143,23 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } - const proved = await input.store.proveOwner({ - sessionId: record.sessionId, - fence, - ...(acquired.link ? { link: acquired.link } : {}), - now: input.now() - }) + const proved = await input.store.proveOwner( + { + sessionId: record.sessionId, + fence, + ...(acquired.link ? { link: acquired.link } : {}), + now: input.now() + }, + input.checkBeforeAcquire + ) return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration } } catch (error) { if (isAgentSessionPreSpawnError(error)) { + physical.processless() throw error } return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } finally { + physical.settled() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 3cd45001fcc1..3ea66105a4a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -153,7 +153,10 @@ export type StructuredAgentSessionAcquireInput = Pick< 'identity' | 'fence' | 'spawnToken' > & Partial> & { + checkBeforeSpawn?: () => void recordPhase?: AgentSessionCreatePhaseRecorder + /** Exact managed-root completion, independent of logical writer retirement. */ + onPhysicalExit?: (exited: Promise) => void /** Persist process identity before handshake so mid-start crashes leave a recoverable owner; returned process must match. */ onSpawned?: (process: AgentSessionProcessIdentity) => Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 6eca137c164d..c8a1de38cc7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -339,6 +339,26 @@ describe('adopting a provider conversation on create', () => { } ) + it('refuses unreadable physical ancestry before acquisition or publication', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-lineage-')) + const { filePath } = await writeCodexHistoryFixture(root, THREAD, ['ice'], { + thread_id: '01a17c7f-6f58-7a73-b87a-a8702ff2da17', + end_ordinal_exclusive: 5, + end_byte_offset: 1024 + }) + const sessionAdapter = adapter() + const publish = vi.fn() + expect(await attach(filePath, sessionAdapter, publish)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_identity_required' } + }) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + expect(sessionAdapter.dispatch).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(store?.getRecord(SESSION)).toBeNull() + expect(store?.listOperationRows()).toEqual([]) + }) + it('still releases acquisition on an import write failure, and leaves the conversation open', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) const transcriptPath = join(root, 'rollout.jsonl') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 071e24190a59..95e286c5b859 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -1,3 +1,4 @@ +import { isNativeConversationMessage } from '../../../shared/agent-session-native-content' import { agentSessionRefusalError, isAgentSessionRefusalError, @@ -77,7 +78,7 @@ async function readAdoptedTranscript( if (!prepared.ok) { throw new Error(prepared.error) } - if (prepared.items.length === 0) { + if (!prepared.items.some(({ body }) => isNativeConversationMessage(body))) { throw agentSessionRefusalError('agent_session_identity_required', { reason: prepared.sourceEmpty ? 'transcriptNotFound' : 'transcriptUnreadable' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index b0c76dbcf3ce..d993355937d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -32,12 +32,16 @@ import { joinClosingStructuredAgentSessionChild, releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' -export { joinClosingStructuredAgentSessionChild } import { refusedStructuredAgentSessionStart, structuredAgentSessionStartParams } from './structured-agent-session-start-eligibility' +// Only queued work or provider operations start children. Run waits outside serialization; +// fence each state commit so concurrent sends remain immediately acceptable. + +export { joinClosingStructuredAgentSessionChild } + /** Preserve attach refusal verdicts so callers distinguish recovery in progress from permanent loss. */ export type StructuredAgentSessionResumeOutcome = | { ok: true } @@ -54,19 +58,23 @@ export type StructuredAgentSessionResumeOutcome = retry?: boolean failure?: SubmissionRejectionFact startKey?: string | null + failureItemId?: string } const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' export type StructuredAgentSessionStartClaim = { outcome: Promise + ownsStart?: true } /** Claim under the ordered decision; wait outside its lane. */ export function claimStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, sessionId: string, - startedFor?: string + startedFor?: string, + check?: () => void, + switchStart = false ): StructuredAgentSessionStartClaim { try { context.deps.store.requireWritableRecord(sessionId) @@ -89,11 +97,14 @@ export function claimStructuredAgentSessionAgent( return { outcome: Promise.resolve({ ok: true }) } } return { + ownsStart: true, outcome: registerStructuredAgentSessionStart( context, sessionId, startedFor, - (lock, attemptId) => runStart(context, sessionId, startedFor, lock, attemptId) + (lock, attemptId) => + runStart(context, sessionId, startedFor, lock, attemptId, check, switchStart), + switchStart ) } } @@ -120,10 +131,14 @@ export function startStructuredAgentSessionAgentForOperation( export function claimOperationStart( context: StructuredAgentSessionAttachContext, - sessionId: string + sessionId: string, + check?: () => void, + switchStart = false ): StructuredAgentSessionStartClaim { - const claim = claimStructuredAgentSessionAgent(context, sessionId) + check?.() + const claim = claimStructuredAgentSessionAgent(context, sessionId, undefined, check, switchStart) return { + ...claim, outcome: claim.outcome.catch((error: unknown) => { context.deps.logger.warn('starting the agent for an operation failed', { scope: 'operation-agent-start', @@ -140,8 +155,13 @@ async function runStart( sessionId: string, startedFor: string | undefined, lock: StructuredAgentSessionAttachLock, - attemptId: string + attemptId: string, + check?: () => void, + switchStart = false ): Promise { + if (!switchStart && context.waitSwitchPhysical) { + await lock.wait(context.waitSwitchPhysical(sessionId)) + } // Never start beside a closing child; unverifiable exit refuses the restart. const closed = await joinClosingStructuredAgentSessionChild(context, sessionId, lock) if (!closed.ok) { @@ -171,6 +191,7 @@ async function runStart( try { attached = await attachStructuredAgentSessionForStart(context, callerKey, params, { lock, + checkBeforeAcquire: check, startupAttemptId: attemptId, ...(startedFor === undefined ? {} : { startedFor }), ...(startupOptions ? { startupOptions } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-catch-up.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-catch-up.ts new file mode 100644 index 000000000000..4aa53d512d7c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-catch-up.ts @@ -0,0 +1,188 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { asRecord, parseJsonObject } from '../../ai-vault/session-scanner-values' +import { + readCodexHistoryJournalView, + sameCodexHistoryJournalView +} from '../../codex/codex-history-journal-view' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createLegacyIdentityTracker } from '../agent-session-journal/journal-legacy-identity' +import { + legacyItemBody, + LEGACY_IMPORT_BLOCK_LIMITS +} from '../agent-session-journal/journal-legacy-import' +import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' +import { splitTranscriptStreamLines } from '../transcript-stream-lines' +import { + decodeCodexTranscriptLine, + isCodexVisibleHistoryRecord +} from '../transcript-line-decoders-codex' +import { decodeCodexTurnLifecycle } from '../transcript-turn-lifecycle' + +/** Refresh a retained read owner without starting a provider or replacing native submissions. */ +export async function catchUpStructuredSessionAtRest(input: { + journal: AgentSessionJournal + threadId: string + filePath: string + fence: number + deadline: AgentSessionSwitchDeadline + stoppedTurnId?: string + check?: () => void +}): Promise { + const { journal, threadId, deadline } = input + const view = readCodexHistoryJournalView(journal, threadId) + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: threadId + }) + if (view.unresolvedOrigin) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + const items: JournalReplacementItem[] = [] + let bytes = 0 + const retain = (item: JournalReplacementItem) => { + bytes += Buffer.byteLength(JSON.stringify(item)) + if (bytes > 16 * 1024 * 1024) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + items.push(item) + } + let turn: string | undefined + let include = false + let identityConfirmed = false + let paginated = false + let index = 0 + const stream = createReadStream(input.filePath, { signal: deadline.signal }) + for await (const { line } of splitTranscriptStreamLines(stream, 16 * 1024 * 1024)) { + deadline.check() + const currentLineIndex = index++ + const record = parseJsonObject(line) + const payload = asRecord(record?.payload) + if (record?.type === 'session_meta') { + if (payload?.id !== threadId) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + identityConfirmed = true + paginated = payload?.history_mode === 'paginated' + } + const lifecycle = decodeCodexTurnLifecycle(line, '') + if (lifecycle?.state === 'working') { + if (include) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + turn = lifecycle.turnId || undefined + include = Boolean( + turn && + !view.known.has(turn) && + lifecycle.timestamp !== null && + (view.importedAt === null || lifecycle.timestamp > view.importedAt) + ) + } + if (!include || !turn) { + continue + } + if (lifecycle && lifecycle.state !== 'working') { + if (lifecycle.turnId !== turn) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + retain({ + identity: { + provider: 'legacy', + agent: 'codex', + sessionId: threadId, + recordId: `turn-lifecycle:${turn}` + }, + body: { + kind: 'turn', + turnId: turn, + state: lifecycle.state, + ...(lifecycle.outcome + ? { outcome: lifecycle.outcome } + : lifecycle.state === 'interrupted' && turn === input.stoppedTurnId + ? { outcome: 'cancellation' as const } + : {}), + ...(lifecycle.timestamp === null ? {} : { completedAt: lifecycle.timestamp }) + }, + ownerFence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + include = false + continue + } + if (!isCodexVisibleHistoryRecord(record, paginated)) { + continue + } + const message = decodeCodexTranscriptLine(line, `#${currentLineIndex}`, { historyImport: true }) + if (message) { + retain({ + identity: tracker.identify(line, currentLineIndex), + body: legacyItemBody(message, LEGACY_IMPORT_BLOCK_LIMITS), + ownerFence: 0, + turnScope: { + kind: 'turn', + turnItemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: threadId, + recordId: `turn-lifecycle:${turn}` + }) + }, + ...(message.timestamp === null ? {} : { observedAt: message.timestamp }) + }) + } + } + deadline.check() + if (!identityConfirmed) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + // Incomplete source turns must remain retryable rather than being declared covered. + if (include && turn !== input.stoppedTurnId) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + if (!items.length) { + return + } + const digest = createHash('sha256').update(JSON.stringify(items)).digest('hex') + await journal.appendPlannedLifecycleBatch({ + settlementId: `at-rest:${digest}`, + fence: input.fence, + plan: () => { + deadline.check() + input.check?.() + if (!sameCodexHistoryJournalView(view, readCodexHistoryJournalView(journal, threadId))) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + return { + dispatches: [], + mutations: items + .filter((item) => !journal.item(agentJournalItemKey(item.identity))) + .map((item) => ({ + kind: 'item' as const, + ...item, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + })) + } + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 600179c14189..d8a67fddbb4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -35,6 +35,7 @@ export type StructuredAgentSessionAttachContext = { } tasks: StructuredAgentSessionTaskQueue reconcileLeases: (sessionId: string) => Promise + waitSwitchPhysical?: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void @@ -54,6 +55,7 @@ export type StructuredAgentSessionAttachContext = { } export type StructuredAgentSessionAttachOptions = { + checkBeforeAcquire?: () => void hostLaunchDirectory?: string /** The saved list a new chat's options are admitted against, for the folder its picker read. */ catalogModels?: AttachFlowInput['models'] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 36407701b300..09670cf1e305 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -58,6 +58,7 @@ import { export type AttachedOwner = 'acquired' | 'kept' | 'none' export type AttachFlowInput = { + checkBeforeAcquire?: () => void store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter agents: Pick @@ -95,6 +96,7 @@ export type AttachFlowInput = { eventSink?: StructuredAgentSessionEventSink /** Prevent acquisition-window events from reaching superseded journals. */ onAcquiring?: () => Promise | void + onPhysicalExit?: (exited: Promise) => void /** Refuses the spawn while a terminal may still be writing the record's conversation. */ assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise /** Settles writes already captured by the superseded journal before opening another. */ @@ -150,8 +152,9 @@ export async function performAttach( } try { const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => - lock.commit(() => - store.reserveOwner( + lock.commit(() => { + input.checkBeforeAcquire?.() + return store.reserveOwner( reserveRequestFor({ sessionId, params, @@ -160,9 +163,10 @@ export async function performAttach( fingerprint: admitted.fingerprint, now: input.now(), models: input.models - }) + }), + input.checkBeforeAcquire ) - ) + }) ) record = reserved.record replayed = reserved.disposition === 'replayed' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-options.ts new file mode 100644 index 000000000000..bf4ce2b4999c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-options.ts @@ -0,0 +1 @@ +export type { StructuredAgentSessionAttachOptions } from './structured-agent-session-attach-context' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index a4ab8a0cbbba..1b073319554b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -3,7 +3,6 @@ import { readBoundChildRewind, type AttachAttempt } from './structured-agent-session-attach-publish' - import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import { refuse } from '../../../shared/agent-session-wire-refusals' import { randomUUID } from 'node:crypto' @@ -113,7 +112,6 @@ export function attachStructuredAgentSession( } }) } - async function runAttach( context: StructuredAgentSessionAttachContext, callerKey: string, @@ -194,6 +192,12 @@ async function runAttachUnderAbort( models: priorRecord ? undefined : options.catalogModels, store: context.deps.store, adapter: context.deps.adapter, + onPhysicalExit: (exited) => + context.runtimeState.startFlights.observePhysicalExit( + sessionId, + exited, + priorDeathEvidence?.kind !== 'kill-delivered' + ), agents: context.deps.agents, logger: context.deps.logger, eventSink: attemptSink.sink, @@ -220,6 +224,7 @@ async function runAttachUnderAbort( now: () => context.now(), recordPhase, acquireSignal: lock.signal, + checkBeforeAcquire: options.checkBeforeAcquire, lock, ...(options.startupAttemptId ? { startupAttemptId: options.startupAttemptId } : {}), onStartupAttempt: (startup) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 5478e617090b..8931a19b56b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -1,6 +1,4 @@ -// Atomically accept Stop/withdrawals/receipt before acting on the captured target. -// Queue session-ending wind-down in the mutation’s tick; saved failures answer from the receipt. - +import { acceptCapturedChatStop } from './structured-agent-session-stop-acceptance' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -20,7 +18,6 @@ import { openForWrite, structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { acceptChatStop, acceptStopTarget } from './structured-agent-session-stop-acceptance' import { noteStructuredAgentSessionStopUnconfirmed, writeStructuredAgentSessionStopNote @@ -39,6 +36,8 @@ import { structuredAgentSessionStopNoteIdentity, structuredAgentSessionCommandWasStopped } from './structured-agent-session-command-turn' +// Atomically accept Stop/withdrawals/receipt before acting on the captured target. +// Queue session-ending wind-down in the mutation’s tick; saved failures answer from the receipt. type ChatStopOutcome = TurnOutcome @@ -52,6 +51,8 @@ export function mutateWithChatStop( turnId?: string stoppedPrePrompt?: boolean stoppedSend?: boolean + onAccepted?: (child: ChatStopTarget['child']) => void + waitForShutdown?: boolean }, plan: MutationPlan, run: ( @@ -79,46 +80,6 @@ export function mutateWithChatStop( error }) // Failed durable acceptance must leave the agent running. - const accept = async ( - ctx: AgentSessionTurnContext, - target: ChatStopTarget - ): Promise> => { - if (!target.marks) { - const accepted = await acceptStopTarget(ctx) - return accepted.ok ? { ok: true, value: { settled: false, turnId: null } } : accepted - } - const sinceStop = ctx.journal.queuedMessages.userStopInForce()?.sequence - const handed = ctx.journal - .submissions() - .some( - (entry) => - entry.fence === ctx.fence && - (entry.handoverRecorded !== true || entry.handedOverAt !== undefined) && - (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && !entry.recovered)) && - (sinceStop === undefined || (entry.submittedSequence ?? -1) > sinceStop) - ) - const accepted = await acceptChatStop(ctx, { - event: { - reason: 'user-stop', - caller: caller.callerKey, - ...(target.eventTurnId !== null ? { turnId: target.eventTurnId } : {}) - }, - fence: ctx.fence - }) - return accepted.ok - ? { - ok: true, - value: { - settled: - (accepted.value.withdrawn?.length ?? (target.queued ? 1 : 0)) > 0 || - handed || - params.stoppedSend === true, - turnId: accepted.value.turnId - } - } - : accepted - } const act = async ( ctx: AgentSessionTurnContext, target: ChatStopTarget, @@ -232,10 +193,11 @@ export function mutateWithChatStop( target.turnId !== null && isStructuredAgentSessionCommandTurnId(target.turnId) && structuredAgentSessionCommandWasStopped(ctx.journal, target.turnId) - const accepted = await accept(ctx, target) + const accepted = await acceptCapturedChatStop(caller.callerKey, params.stoppedSend, ctx, target) if (!accepted.ok) { return accepted } + params.onAccepted?.(target.child) for (const abort of operations) { abort() reached = true @@ -312,5 +274,10 @@ export function mutateWithChatStop( } }) void shutdown.catch(logChildEnd) - return result + return params.waitForShutdown + ? result.then(async (answer) => { + await shutdown + return answer + }) + : result } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 6de62e9e4137..6f2234cffff8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -18,10 +18,12 @@ export async function joinStructuredAgentSessionChildClose( context: StructuredAgentSessionLifetimeContext, sessionId: string, child: StructuredAgentSessionProviderChild, + check?: () => void, observeClose?: AgentSessionCloseObserver ): Promise { // Capture startup state before provider release loses that evidence. const unanswered = child.phase === 'starting' + check?.() const closed = await closeProviderRoot(context, sessionId, observeClose) if (!closed) { return 'unverifiable' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1d79897730dd..5ec42789dfa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -2,9 +2,6 @@ import { createStructuredAgentSessionStopStartingChild, type StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host' -import { retryableStructuredAgentSessionStart } from './structured-agent-session-start-retry' -// Open conversations at rest; never start agents for reads. Acquire serialization once because the queue is nonreentrant. - export type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-host-types' import { closeStructuredAgentSessionHandovers } from './structured-agent-session-handing-over' import { @@ -21,30 +18,17 @@ import { type StructuredAgentSessionCloseCause, type StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' -import type { - StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChildIdentity -} from './structured-agent-session-host-types' +import type * as hostTypes from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' -import { - stopWorkspaceSleepChat, - workspaceSleepStartAborts, - type WorkspaceSleepCloseResult -} from './structured-agent-session-workspace-sleep-stop' +import * as sleep from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' -import { - providerStartupFailureFact, - structuredAgentSessionStartFailure -} from './structured-agent-session-failure-text' -import { - oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure -} from './structured-agent-session-start-failure-row' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { settleStructuredAgentSessionFailedStart } from './structured-agent-session-failed-start-settlement' +import { createStructuredAgentSessionSwitchCommands } from './structured-agent-session-switch-commands' + +// Open conversations at rest; never start agents for reads. Acquire serialization once because the queue is nonreentrant. export function createStructuredAgentSessionConversationLifetime( host: StructuredAgentSessionConversationLifetimeHost @@ -52,13 +36,18 @@ export function createStructuredAgentSessionConversationLifetime( let disposed = false const { sessions, serialize } = host const deps = () => host.context().deps + const switchCommands = createStructuredAgentSessionSwitchCommands(host, () => disposed) const readRefusals = createJournalOpenReadRefusals( deferredStructuredAgentSessionLogger(() => deps().logger) ) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const closeConversation = async (sessionId: string, atRest = false): Promise => { + const closeConversation = async ( + sessionId: string, + atRest = false, + ownSwitch = false + ): Promise => { // Retain first-hand exit proof until durable lease release succeeds. if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false @@ -73,19 +62,13 @@ export function createStructuredAgentSessionConversationLifetime( } }, sessionId, - atRest + atRest, + ownSwitch ) } const closeContext = { sessions, serialize, closeConversation, deps } - const hasOpenDispatch = (sessionId: string): boolean => { - const record = deps().store.getConversation(sessionId) - return record !== null && deps().hasOpenDispatch?.(record) === true - } - const providerHoldsDispatch = (sessionId: string): boolean => - deps().adapter.holdsDispatch?.(sessionId) === true - const holdStopClose = async ( sessionId: string, stop: () => Promise, @@ -116,14 +99,16 @@ export function createStructuredAgentSessionConversationLifetime( sessionId: string, workspaceId: string, executionHostId: ExecutionHostId - ): Promise => { + ): Promise => { const matches = (): boolean => { const location = deps().store.getConversation(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId } - const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) + const starts = sleep.workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) // Pending creates have no record yet; their queued close waits for founding. - if (matches() && deps().store.getRecord(sessionId)) { + const matched = matches() + const revoked = matched && switchCommands.revoke(sessionId) + if ((revoked ? matches() : matched) && deps().store.getRecord(sessionId)) { const journal = sessions.get(sessionId)?.journal if (journal) { closeStructuredAgentSessionHandovers(journal) @@ -150,7 +135,7 @@ export function createStructuredAgentSessionConversationLifetime( const stopped = await holdStopClose( sessionId, () => - stopWorkspaceSleepChat({ + sleep.stopWorkspaceSleepChat({ deps: deps(), sessions, stopAgent, @@ -188,15 +173,15 @@ export function createStructuredAgentSessionConversationLifetime( getConversation: (sessionId) => deps().store.getConversation(sessionId), replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId) }, - hasOpenDispatch, - providerHoldsDispatch, - // Host eviction is reportable interruption, not personal cancellation. + hasOpenDispatch: (sessionId) => { + const record = deps().store.getConversation(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + }, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, + // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // Word handed-over and queued startup failures from the same hostStopped fact. - stopStartingAgent: (sessionId) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'host-stop' - }), + stopStartingAgent: (sessionId) => stopAgent(sessionId, { cause: 'host-stop' }), closeConversation: (sessionId) => closeConversation(sessionId, true), logger: deps().logger, ...deps().idleSweep @@ -204,6 +189,7 @@ export function createStructuredAgentSessionConversationLifetime( const stopStartingChild = createStructuredAgentSessionStopStartingChild(host) return { + ...switchCommands.controls, closeForWorkspaceSleep, idleSweep, stopAgent, @@ -213,64 +199,26 @@ export function createStructuredAgentSessionConversationLifetime( stopStartingChild, failStartingChild: ( sessionId: string, - failed: StructuredAgentSessionProviderChildIdentity, + failed: hostTypes.StructuredAgentSessionProviderChildIdentity, error: unknown, openMs: number ): Promise => - serialize(sessionId, async () => { - const session = sessions.get(sessionId) - const child = session?.child - if (!session || !child || child.close || !sameProviderChild(child, failed)) { - return - } - const failure = providerStartupFailureFact(error) - // Persisted facts cannot retain the original error; log it here. - deps().logger.warn('a provider could not finish starting', { - scope: 'provider-open-failed', - sessionId, - failure: failure.kind, - ...failure.requestTimeout, - openMs, - error - }) - const { runtimeState: runtime } = host.context() - const owned = runtime.startFlights.ownsChild(sessionId, child) - if (owned) { - const retry = retryableStructuredAgentSessionStart(error) - runtime.startupAttempts.childFailed(sessionId, child, failure, retry) - } - try { - if (!owned) { - const record = deps().store.getRecord(sessionId) - const wording = structuredAgentSessionFailureWordsContext(record) - await recordStructuredAgentSessionStartFailure( - { journal: session.journal, fence: child.fence }, - { - startKey: child.generation, - ...structuredAgentSessionStartFailure({ failure }, wording) - }, - oldestQueuedSubmission(session)?.clientMessageId ?? null - ).catch((writeError: unknown) => - deps().logger.warn('recording a failed provider open failed', { - scope: 'provider-open-failure', - sessionId, - error: writeError - }) - ) - } - const reason = error instanceof Error ? error.message : String(error) - await stopAgent(sessionId, { cause: 'host-stop', reason, failure }) - } finally { - runtime.startupAttempts.childEnded(sessionId, child) - } - }), + settleStructuredAgentSessionFailedStart(host, stopAgent, sessionId, failed, error, openMs), dispose: (): void => { disposed = true + switchCommands.dispose() idleSweep.dispose() host.context().runtimeState.startupAttempts.dispose() }, - /** Reuse open handles without waiting for startup; serialize new opens. Use before await because close may drop them. */ - conversation: async (sessionId: string): Promise => { + /** + * The only way any code reaches a session. An open conversation answers without the lock, so + * a read never waits behind a start; a closed one is opened once, under it. Nothing here + * touches the lease or starts a child. Use the result before the next `await`: a close can + * drop it after. + */ + conversation: async ( + sessionId: string + ): Promise => { const open = sessions.get(sessionId) if (open) { readRefusals.forget(sessionId) @@ -301,6 +249,7 @@ export function createStructuredAgentSessionConversationLifetime( }, /** Close resources while retaining durable chat/history and personal unsent text for the next turn. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + switchCommands.revoke(sessionId) const journal = sessions.get(sessionId)?.journal if (journal) { closeStructuredAgentSessionHandovers(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-options.ts new file mode 100644 index 000000000000..c93dcf13819b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-options.ts @@ -0,0 +1,20 @@ +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import type { reserveRequestFor } from './structured-agent-session-attach' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' + +export type StructuredAgentSessionCreateOptions = { + check?: () => void + firstMessage?: StructuredAgentSessionFirstMessage + hostLaunchDirectory?: string + catalogModels?: Parameters[0]['models'] + /** Publish after durable creation, before agent delivery starts. */ + publishTab?: () => Promise +} + +/** Opening messages retain creation-time model/effort choices like later sends. */ +export function openingMessageOptions(options: Readonly> | undefined): { + options?: AgentSessionExecutionOptions +} { + const { model, effort } = options ?? {} + return model ? { options: { model, ...(effort ? { effort } : {}) } } : {} +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 1b49a479f330..b86901c8672e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -1,17 +1,17 @@ import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { isAgentSessionCreateAdmissionRefusal } from '../../../shared/agent-session-definitive-refusal' -import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { refuse, type AgentSessionAttachResult, type AgentSessionMutationResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { journalOpenRefusalError } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import { @@ -28,18 +28,12 @@ import { } from './structured-agent-session-provider-support' import { performSend } from './structured-agent-session-turns' import { observedReceipt } from './structured-agent-session-operation-settlement' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' -import { openingMessageOptions } from './structured-agent-session-execution-options' - -export type StructuredAgentSessionCreateOptions = { - firstMessage?: StructuredAgentSessionFirstMessage - hostLaunchDirectory?: string - catalogModels?: Parameters[0]['models'] - /** Publish after durable creation, before agent delivery starts. */ - publishTab?: () => Promise -} +import { + type StructuredAgentSessionCreateOptions, + openingMessageOptions +} from './structured-agent-session-create-options' export function createStructuredAgentSession( context: StructuredAgentSessionAttachContext, @@ -71,6 +65,7 @@ async function foundConversation( const { deps } = context const { store } = deps const sessionId = params.envelope.sessionId + options.check?.() const prior = store.getRecord(sessionId) if (params.envelope.expectedRuntimeFence !== null || params.agent !== params.provider) { return { @@ -224,11 +219,12 @@ async function foundConversation( return sent } } else { - await store.founding.commit(request) + await store.founding.commit(request, options.check) } committed = true } try { + options.check?.() await options.publishTab?.() } catch (error) { deps.logger.warn('publishing the tab of a created chat failed', { @@ -295,3 +291,5 @@ export function structuredAgentSessionEntryDelegates( ) => createStructuredAgentSession(context(), caller.callerKey, params, options) } } + +export type { StructuredAgentSessionCreateOptions } from './structured-agent-session-create-options' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index f01019164dc7..dc9fdaa34b52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -4,7 +4,6 @@ import { } from './structured-agent-session-actionable-prompts' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { compareAgentJournalItems } from '../../../shared/agent-session-journal-position' -import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isHistoricalProviderTimelineItem } from '../agent-session-timeline/provider-timeline-history' import { currentRequestsProjection } from './agent-session-current-requests' // Derive operational work from lease, first-hand exit, replaced-runtime and journal evidence. @@ -17,7 +16,6 @@ import { type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionLatestTurn } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' @@ -28,6 +26,19 @@ import { finalizingStructuredAgentSessionInput, structuredAgentSessionHandingOver } from './structured-agent-session-handing-over' +import { + structuredAgentSessionLiveFence, + structuredAgentSessionOwnerUnverifiable, + type StructuredAgentSessionWorkEvidence, + type StructuredAgentSessionSeenEnd, + type StructuredAgentSessionReplacedEnd +} from './structured-agent-session-work-evidence' + +export { + structuredAgentSessionLiveFence, + structuredAgentSessionReplacedRuntimeEnded, + type StructuredAgentSessionWorkEvidence +} from './structured-agent-session-work-evidence' export type StructuredAgentSessionCurrentWorkJournal = Pick< AgentSessionJournal, @@ -35,74 +46,6 @@ export type StructuredAgentSessionCurrentWorkJournal = Pick< > & Partial> -export type StructuredAgentSessionWorkEvidence = { - record: Pick | null - ended?: StructuredAgentSessionSeenEnd - revision?: number - replaced?: StructuredAgentSessionReplacedEnd | undefined - /** Absent means the host has no child observation. */ - child?: { fence: number; heldSends?: () => ReadonlySet | undefined } | null -} - -/** Replaced runtimes granted all fences through this boundary. */ -type StructuredAgentSessionReplacedEnd = { fence: number } - -type StructuredAgentSessionSeenEnd = { - fence: number - rootGone?: boolean - cause?: string -} - -/** Missing records retain legacy fence-0 behavior; released/ended generations have no live fence. */ -export function structuredAgentSessionLiveFence( - evidence: StructuredAgentSessionWorkEvidence -): number | null { - const record = evidence.record - const lease = record?.ownerObservation - const fence = record?.currentFence - if (!lease) { - return 0 - } - if (lease.claimStatus === 'released') { - return null - } - if ( - structuredAgentSessionReplacedRuntimeEnded( - { runtimeFence: fence ?? 0, claimStatus: lease.claimStatus }, - evidence.replaced - ) || - structuredAgentSessionOwnerUnverifiable(evidence) - ) { - return null - } - const { ended } = evidence - return ended?.rootGone === true && ended.fence === fence ? null : (fence ?? 0) -} - -/** Unreconciled/recovering owners without a local child remain unverifiable, not current work. */ -function structuredAgentSessionOwnerUnverifiable(evidence: StructuredAgentSessionWorkEvidence) { - const record = evidence.record - const lease = record?.ownerObservation - const fence = record?.currentFence - return ( - (lease?.unreconciled === true || lease?.handoffStage === 'recovering') && - evidence.child !== undefined && - evidence.child?.fence !== fence - ) -} - -/** Replaced runtimes lost their pipes; conflicted terminal claims are outside that proof. */ -export function structuredAgentSessionReplacedRuntimeEnded( - lease: Pick, - replaced: StructuredAgentSessionReplacedEnd | undefined -): boolean { - return ( - replaced !== undefined && - lease.runtimeFence <= replaced.fence && - lease.claimStatus !== 'conflicted' - ) -} - function structuredAgentSessionHandsOver( journal: Pick, submission: Pick< @@ -190,6 +133,21 @@ export class StructuredAgentSessionCurrentWork { ) } + holdsHandedOverSend(): boolean { + const submissions = this.unansweredSends() + if (submissions.length === 0) { + return false + } + const held = this.heldSends?.() + const handingOver = structuredAgentSessionHandingOver(this.journal) + return ( + held === undefined || + submissions.some( + ({ clientMessageId }) => held.has(clientMessageId) || handingOver.has(clientMessageId) + ) + ) + } + isCurrentItem(itemId: string): boolean { if (this.liveFence === null) { return false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-start-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-start-settlement.ts new file mode 100644 index 000000000000..d6d14d5aa528 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-start-settlement.ts @@ -0,0 +1,76 @@ +import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host' +import { retryableStructuredAgentSessionStart } from './structured-agent-session-start-retry' + +import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type * as hostTypes from './structured-agent-session-host-types' + +import { sameProviderChild } from './structured-agent-session-provider-child' + +import { + providerStartupFailureFact, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { + oldestQueuedSubmission, + recordStructuredAgentSessionStartFailure +} from './structured-agent-session-start-failure-row' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +export function settleStructuredAgentSessionFailedStart( + host: StructuredAgentSessionConversationLifetimeHost, + stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise, + sessionId: string, + failed: hostTypes.StructuredAgentSessionProviderChildIdentity, + error: unknown, + openMs: number +): Promise { + const { serialize, sessions } = host + const deps = () => host.context().deps + return serialize(sessionId, async () => { + const session = sessions.get(sessionId) + const child = session?.child + if (!session || !child || child.close || !sameProviderChild(child, failed)) { + return + } + const failure = providerStartupFailureFact(error) + // Persisted facts cannot retain the original error; log it here. + deps().logger.warn('a provider could not finish starting', { + scope: 'provider-open-failed', + sessionId, + failure: failure.kind, + ...failure.requestTimeout, + openMs, + error + }) + const { runtimeState: runtime } = host.context() + const owned = runtime.startFlights.ownsChild(sessionId, child) + if (owned) { + const retry = retryableStructuredAgentSessionStart(error) + runtime.startupAttempts.childFailed(sessionId, child, failure, retry) + } + try { + if (!owned) { + const record = deps().store.getRecord(sessionId) + const wording = structuredAgentSessionFailureWordsContext(record) + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: child.fence }, + { + startKey: child.generation, + ...structuredAgentSessionStartFailure({ failure }, wording) + }, + oldestQueuedSubmission(session)?.clientMessageId ?? null + ).catch((writeError: unknown) => + deps().logger.warn('recording a failed provider open failed', { + scope: 'provider-open-failure', + sessionId, + error: writeError + }) + ) + } + const reason = error instanceof Error ? error.message : String(error) + await stopAgent(sessionId, { cause: 'host-stop', reason, failure }) + } finally { + runtime.startupAttempts.childEnded(sessionId, child) + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 5e3d17d39ea1..6292daa284a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -51,7 +51,7 @@ const TYPED_START_REFUSALS = [ ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] -function typedStartRefusal( +export function typedStartRefusal( reason: string | undefined ): (typeof TYPED_START_REFUSALS)[number] | undefined { return TYPED_START_REFUSALS.find((typed) => typed === reason) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-contexts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-contexts.ts new file mode 100644 index 000000000000..cbeb57f14d5e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-contexts.ts @@ -0,0 +1,89 @@ +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' +import type * as lifetime from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type * as hostMutations from './structured-agent-session-host-mutations' +import type * as HostTypes from './structured-agent-session-host-types' +import type { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type * as conversation from './structured-agent-session-host-delivery' +import type { wireStructuredAgentSessionQueuedMessages as wireQueuedMessages } from './structured-agent-session-queued-wiring' +import { sleepStructuredAgentSessionWorkspace } from './structured-agent-session-workspace-sleep' + +export abstract class StructuredAgentSessionHostContexts { + abstract readonly deps: HostTypes.StructuredAgentSessionHostDeps + protected abstract readonly sessions: StructuredAgentSessionConversations + protected abstract readonly subscribers: StructuredAgentSessionClientDelivery['subscribers'] + protected abstract readonly tasks: StructuredAgentSessionTaskQueue + protected abstract readonly runtimeState: StructuredAgentSessionHostRuntimeState + protected abstract readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime + protected abstract readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery + protected abstract readonly eventRecovery: StructuredAgentSessionEventRecovery + protected abstract readonly clientDelivery: StructuredAgentSessionClientDelivery + protected abstract readonly queued: ReturnType + protected abstract now: () => number + protected abstract serialize: StructuredAgentSessionTaskQueue['serialize'] + // Keep inference so publishStatus stays required in the attach-context spread. + protected lifetimeContext() { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + now: () => this.now(), + publishStatus: this.clientDelivery.publishStatus, + generationEnded: this.clientDelivery.publishGenerationEnded, + wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), + endExitedChild: this.eventRecovery.endExitedChildUnderSerialize + } satisfies StructuredAgentSessionLifetimeContext + } + + protected attachContext(): StructuredAgentSessionAttachContext { + return { + ...this.lifetimeContext(), + subscribers: this.subscribers, + tasks: this.tasks, + reconcileLeases: (sessionId) => this.runtimeState.reconcileLeases(sessionId), + waitSwitchPhysical: (sessionId) => this.lifetime.waitSwitchPhysical(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + openProviderChild: this.eventRecovery.openProviderChild, + openConversation: this.conversationDelivery.open + } + } + + /** Stop hidden children too, using one bounded host snapshot. */ + sleepWorkspace = (workspaceId: string, markAsleep?: boolean) => + sleepStructuredAgentSessionWorkspace( + { + deps: this.deps, + tasks: this.tasks, + lifetime: this.lifetime, + sessions: this.sessions, + // Starts registered after Stop are newer work. + deliveryRunning: (id) => + this.conversationDelivery.loop.isRunning(id) || this.runtimeState.startFlights.has(id), + childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + publishRecordNotes: this.clientDelivery.publishRecordNotes + }, + workspaceId, + markAsleep + ) + + protected mutationContext = (): hostMutations.StructuredAgentSessionMutationContext => + createStructuredAgentSessionHostMutationContext({ + deps: this.deps, + sessions: this.sessions, + subscribers: this.subscribers, + lifetime: this.lifetime, + clientDelivery: this.clientDelivery, + conversationDelivery: this.conversationDelivery, + queued: this.queued, + runtimeState: this.runtimeState, + attachContext: () => this.attachContext(), + trackProviderOperation: (operation) => this.tasks.trackAttach(operation), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 74afc4ea313e..e62e5b1aa8a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -120,7 +120,8 @@ export async function holdRestartedStructuredAgentSessionSends( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - ending: StructuredAgentSessionStopEnding + ending: StructuredAgentSessionStopEnding, + check?: () => void ): Promise { const session = context.sessions.get(sessionId) const child = session?.child @@ -133,6 +134,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const recorded = (await stopEndsWork(context, sessionId, session, ending)) ? recordStopEvent(context, sessionId, session, ending) : Promise.resolve(null) + check?.() child.close = { cause, ...('quit' in ending && ending.quit ? { quit: true as const } : {}), @@ -161,11 +163,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( () => context.restartWitness?.beforeStop(sessionId) ) } + check?.() if ( (await joinStructuredAgentSessionChildClose( context, sessionId, child, + check, 'recorded' in ending ? ending.observeClose : undefined )) !== 'exited' ) { @@ -205,9 +209,11 @@ export type StructuredAgentSessionCloseCause = Extract< /** Idle cache eviction also retains waiting cards so unseen closes cannot introduce reopen pauses. */ export function structuredAgentSessionConversationClosable( session: StructuredAgentSessionHostSession, - atRest = false + atRest = false, + ownSwitch = false ): boolean { return ( + (!session.switchCommand || ownSwitch) && session.child === null && ![...structuredAgentSessionHandingOver(session.journal).keys()].some((id) => finalizingStructuredAgentSessionInput(session.journal, id) @@ -224,10 +230,11 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( closeStatus: (sessionId: string) => void }, sessionId: string, - atRest = false + atRest = false, + ownSwitch = false ): Promise { const session = context.sessions.get(sessionId) - if (!session || !structuredAgentSessionConversationClosable(session, atRest)) { + if (!session || !structuredAgentSessionConversationClosable(session, atRest, ownSwitch)) { return false } context.sessions.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts index f6ae8c867d79..1417ec354672 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts @@ -50,7 +50,8 @@ export function createStructuredAgentSessionHostMutationContext( childWorkIncomplete: input.clientDelivery.childWorkIncomplete, serialize: input.serialize, openConversation: input.conversationDelivery.open, - claimAgent: (sessionId) => agentStart.claimOperationStart(input.attachContext(), sessionId), + claimAgent: (sessionId, check, switchStart) => + agentStart.claimOperationStart(input.attachContext(), sessionId, check, switchStart), startFlights: input.runtimeState.startFlights, startupAttempts: input.runtimeState.startupAttempts, deliveryOrder: input.runtimeState.deliveryOrder, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 4ec4b8c9b1a1..d14571eee1a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -1,10 +1,11 @@ -// Share mutation admission/planning/publication. Conversation writes need no child lease; -// provider-only effects start their child before admission, sends start through delivery. - import { latestNativeChatUsageLimitCut } from '../../../shared/native-chat/native-chat-usage-limit-cut' import { refuse } from '../../../shared/agent-session-wire' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { shouldQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import { + startStructuredAgentSessionForSwitch, + openStructuredAgentSessionForSwitch +} from './structured-agent-session-switch-to-chat' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -47,7 +48,6 @@ import { openWithReadyStructuredAgentSessionChild, runAfterRewindStart } from './structured-agent-session-ready-operation' -export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { readStructuredAgentSessionOptions, @@ -56,6 +56,12 @@ import { type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' + +// Share mutation admission/planning/publication. Conversation writes need no child lease; +// provider-only effects start their child before admission, sends start through delivery. + +export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import { confirmIdleStructuredAgentSessionOption } from './structured-agent-session-idle-option' export function sendStructuredAgentSessionTurn( @@ -130,6 +136,8 @@ export function cancelStructuredAgentSessionTurn( scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } + onAccepted?: Parameters[2]['onAccepted'] + waitForShutdown?: boolean } ): Promise> { if (params.scope) { @@ -300,6 +308,10 @@ export function structuredAgentSessionMutationDelegates( reader: StructuredAgentSessionOptionsReader = IN_PROCESS_OPTIONS_READER ) => readStructuredAgentSessionOptions(context(), sessionId, reader), settleLateDispatch: (input: Parameters[1]) => - settleStructuredAgentSessionLateDispatch(context(), input) + settleStructuredAgentSessionLateDispatch(context(), input), + openSwitchToChat: (sessionId: string, deadline?: AgentSessionSwitchDeadline) => + openStructuredAgentSessionForSwitch(context(), sessionId, deadline), + switchToChat: (sessionId: string, deadline?: AgentSessionSwitchDeadline) => + startStructuredAgentSessionForSwitch(context(), sessionId, deadline) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 98c92142a74a..8adaa502403e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -171,10 +171,9 @@ export type StructuredAgentSessionHostDeps = { hasOpenDispatch?: ( record: Pick ) => boolean - /** Explicit switch admission also includes direct chat assignments, even at rest. */ - hasOwedSwitchWork?: (record: AgentSessionRecord) => boolean /** Advisory tab-hidden notice; failures are logged. */ onSessionTabHidden?: (sessionId: string) => void + captureSessionSwitchStop?: (sessionId: string) => ((proven: boolean) => void) | undefined /** Throws while a terminal may still write the record's head conversation; run before a spawn. */ assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise /** Where every failure the host carries on past is reported. Required: a host without one would diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 444dbb532ca8..6d701656e999 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,10 +1,9 @@ +import { structuredAgentSessionSwitchSource } from './structured-agent-session-switch-source' import { continuationContextReader as contextReader } from './structured-agent-session-continuation-context' import { structuredAgentStartJournal } from './structured-agent-start-journal' import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' -// Serialize durable mutations per conversation; open at rest and start a child only when work needs it. - import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { createStartupRecovery } from './structured-agent-session-restart-reconcile' @@ -13,13 +12,10 @@ import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' -import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' -import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' import * as lifetime from './structured-agent-session-conversation-lifetime' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { createStructuredAgentSessionTabSurface as createTabs } from './structured-agent-session-host-tabs' -import { structuredAgentSessionMutationDelegates } from './structured-agent-session-host-mutations' +import * as hostMutations from './structured-agent-session-host-mutations' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type * as HostTypes from './structured-agent-session-host-types' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' @@ -31,24 +27,30 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import * as conversation from './structured-agent-session-host-delivery' import { wireStructuredAgentSessionQueuedMessages as wireQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' -import { createStructuredAgentSessionWorkspaceSleeper } from './structured-agent-session-workspace-sleep' +import { StructuredAgentSessionHostContexts } from './structured-agent-session-host-contexts' +import { + agentSessionSwitchDeadline, + type AgentSessionSwitchDeadline +} from '../../../shared/agent-session-switch-execution' +// Serialize durable mutations per conversation; open at rest and start a child only when work needs it. + export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -export class StructuredAgentSessionHost { +export class StructuredAgentSessionHost extends StructuredAgentSessionHostContexts { private readonly conversationCommands = new StructuredConversationCommandController( () => this.mutationContext(), this ) - private readonly sessions = createStructuredAgentSessionPublicationWiring({ + protected readonly sessions = createStructuredAgentSessionPublicationWiring({ client: () => this.clientDelivery, delivery: () => this.conversationDelivery, opened: (id) => this.queued.onOpened(id), logger: () => this.deps.logger, now: () => this.now() }) - private readonly queued = wireQueuedMessages(this.sessions, () => this.mutationContext()) + protected readonly queued = wireQueuedMessages(this.sessions, () => this.mutationContext()) // Journal publication renews idle activity. - private readonly clientDelivery = new StructuredAgentSessionClientDelivery( + protected readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), () => this.deps, @@ -59,18 +61,19 @@ export class StructuredAgentSessionHost { (sessionId) => this.runtimeState?.startFlights.projectsStarting(sessionId) ?? false, this.sessions ) - private readonly subscribers = this.clientDelivery.subscribers - private readonly tasks = new StructuredAgentSessionTaskQueue() - private readonly runtimeState: StructuredAgentSessionHostRuntimeState + protected readonly subscribers = this.clientDelivery.subscribers + protected readonly tasks = new StructuredAgentSessionTaskQueue() + protected readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly startup: ReturnType readonly reconcileRestartLeases = () => this.startup.reconcileRestartLeases() - private readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime - private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery - private readonly eventRecovery: StructuredAgentSessionEventRecovery + protected readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime + protected readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery + protected readonly eventRecovery: StructuredAgentSessionEventRecovery private readonly backgroundTasks: BackgroundTasks readonly restartResume: resume.StructuredAgentSessionRestartResume constructor(readonly deps: HostTypes.StructuredAgentSessionHostDeps) { + super() // Collaborators share a logger wrapper that cannot fail the operation it reports. this.deps = deps = sessionLogger.withNeverThrowingLogger(deps) this.clientDelivery.watchAtRestCommands(deps.adapter) @@ -142,7 +145,7 @@ export class StructuredAgentSessionHost { this.lifetime.idleSweep.start() } - private now = (): number => this.deps.now?.() ?? Date.now() + protected now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) loadedSessionIds = (): string[] => @@ -153,45 +156,56 @@ export class StructuredAgentSessionHost { handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) - // Keep inference so publishStatus stays required in the attach-context spread. - private lifetimeContext() { - return { - deps: this.deps, - runtimeState: this.runtimeState, - sessions: this.sessions, - now: () => this.now(), - publishStatus: this.clientDelivery.publishStatus, - generationEnded: this.clientDelivery.publishGenerationEnded, - wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), - endExitedChild: this.eventRecovery.endExitedChildUnderSerialize - } satisfies StructuredAgentSessionLifetimeContext - } - - private attachContext(): StructuredAgentSessionAttachContext { - return { - ...this.lifetimeContext(), - subscribers: this.subscribers, - tasks: this.tasks, - reconcileLeases: (sessionId) => this.runtimeState.reconcileLeases(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task), - openProviderChild: this.eventRecovery.openProviderChild, - openConversation: this.conversationDelivery.open - } - } /** Release resources; user-close cancels cut turns, while eviction leaves their failure reportable. */ close: lifetime.StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) - - /** Stop hidden children too, using one bounded host snapshot. */ - sleepWorkspace = createStructuredAgentSessionWorkspaceSleeper(() => ({ - deps: this.deps, - tasks: this.tasks, - lifetime: this.lifetime, - sessions: this.sessions, - conversationDelivery: this.conversationDelivery, - runtimeState: this.runtimeState, - clientDelivery: this.clientDelivery - })) + switchToTerminal = async (sessionId: string, deadline?: AgentSessionSwitchDeadline) => { + const budget = deadline ?? agentSessionSwitchDeadline() + try { + await this.stopForSwitch(sessionId, { callerKey: 'trusted-local:session-switch' }, budget) + } finally { + if (!deadline) { + budget.dispose() + } + } + } + runSwitch: lifetime.StructuredAgentSessionConversationLifetime['runSwitch'] = (...args) => + this.lifetime.runSwitch(...args) + adoptSwitch = (from: string, to: string) => this.lifetime.adoptSwitch(from, to) + switchGeneration = (id: string) => this.lifetime.switchGeneration(id) + retainSwitch = (id: string) => this.lifetime.retainSwitch(id) + guardSwitchWork = (id: string, current: () => boolean) => + this.lifetime.guardSwitchWork(id, current) + trackSwitchPhysical = (id: string, work: Promise, recover?: () => Promise) => + this.lifetime.trackSwitchPhysical(id, work, recover) + + /** Ordinary Stop owns interruption; only its captured child may be physically closed. */ + + private switchSource = () => + structuredAgentSessionSwitchSource({ + deps: this.deps, + cancel: this.cancel, + switchGeneration: this.switchGeneration, + guardSwitchWork: this.guardSwitchWork, + startFlights: this.runtimeState.startFlights, + now: this.now, + lifetime: this.lifetime, + sessions: this.sessions, + serialize: this.serialize, + subscribers: this.subscribers + }) + stopForSwitch = ( + ...args: Parameters['stopForSwitch']> + ) => this.switchSource().stopForSwitch(...args) + refreshSwitchTranscript = ( + ...args: Parameters< + ReturnType['refreshSwitchTranscript'] + > + ) => this.switchSource().refreshSwitchTranscript(...args) + captureSwitchWork = ( + ...args: Parameters['captureSwitchWork']> + ) => this.switchSource().captureSwitchWork(...args) + switchExecuting = (id: string) => this.lifetime.switchExecuting(id) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -225,7 +239,7 @@ export class StructuredAgentSessionHost { revealSession = (sessionId: string): Promise => reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) - private serialize = this.tasks.serialize.bind(this.tasks) + protected serialize = this.tasks.serialize.bind(this.tasks) private readonly entry = structuredAgentSessionEntryDelegates(() => this.attachContext()) attach = this.entry.attach @@ -252,22 +266,6 @@ export class StructuredAgentSessionHost { }).finally(() => this.clientDelivery.closeAll()) } - private mutationContext = () => - createStructuredAgentSessionHostMutationContext({ - deps: this.deps, - sessions: this.sessions, - subscribers: this.subscribers, - lifetime: this.lifetime, - clientDelivery: this.clientDelivery, - conversationDelivery: this.conversationDelivery, - queued: this.queued, - runtimeState: this.runtimeState, - attachContext: () => this.attachContext(), - trackProviderOperation: (operation) => this.tasks.trackAttach(operation), - serialize: (sessionId, task) => this.serialize(sessionId, task), - now: () => this.now() - }) - send = this.conversationCommands.send queuedMessageSend = this.queued.queuedMessageSend @@ -279,12 +277,17 @@ export class StructuredAgentSessionHost { waitForSendSettlement = this.clientDelivery.waitForSendSettlement - private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext()) + private mutations = hostMutations.structuredAgentSessionMutationDelegates(() => + this.mutationContext() + ) cancel = this.mutations.cancel respondToPrompt = this.mutations.respondToPrompt setOption = this.mutations.setOption changeThreadGoal = this.mutations.changeThreadGoal readOptions = this.mutations.readOptions + /** A terminal's conversation moves into this chat: its agent starts and is ready, or why not. */ + switchToChat = this.mutations.switchToChat + openSwitchToChat = this.mutations.openSwitchToChat rewind = (caller: HostTypes.StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 70012ad62549..e28218cb5955 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -141,8 +141,9 @@ export class StructuredAgentSessionIdleSweep { ) } - /** Without a child, leftover turns/prompts are history and cannot retain the handle. */ - private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the + * handle, and a leftover prompt or turn row is only history. */ + owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { return ( this.queuedOrDelivering(sessionId, session) || structuredAgentSessionChildHasOpenWork({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index fb51feb722ff..d25a0cdfd62c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -44,7 +44,11 @@ export type StructuredAgentSessionMutationContext = { /** Open within the caller’s existing serialization. */ openConversation: (sessionId: string) => Promise /** Claim inside serialization; await the result outside. */ - claimAgent: (sessionId: string) => StructuredAgentSessionStartClaim + claimAgent: ( + sessionId: string, + check?: () => void, + switchStart?: boolean + ) => StructuredAgentSessionStartClaim startFlights: Pick startupAttempts: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 79d0335ac40a..31e031ac4887 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -145,24 +145,34 @@ export async function createRestTestRig( readChildWork: vi.fn((): AgentChildWorkView[] => []) } let store = await openTestAgentSessionRecordStore(root) + const physical = new Map>>() const adapter: RestTestAdapter = { open: async () => ({ reportedOptions: { model: '' } }), - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: `generation-${++generations}`, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: clock.now + acquire: vi.fn(async ({ identity, fence, spawnToken, onPhysicalExit }) => { + const exited = Promise.withResolvers() + physical.set(identity.sessionId, exited) + onPhysicalExit?.(exited.promise) + return { + acquisitionGeneration: `generation-${++generations}`, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: clock.now + } } - })), - closeSession: vi.fn(async () => true), + }), + closeSession: vi.fn(async (sessionId) => { + physical.get(sessionId)?.resolve() + physical.delete(sessionId) + return true + }), dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), holdsDispatch: vi.fn(() => false), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts index 2d592878ad46..252d20ee2fff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts @@ -31,7 +31,8 @@ export function registerStructuredAgentSessionStart( run: ( lock: StructuredAgentSessionAttachLock, attemptId: string - ) => Promise + ) => Promise, + recordUnrequestedFailure = false ): Promise { const { startFlights: flights, startupAttempts: attempts, acquireAborts } = context.runtimeState const acquire = acquireAborts.begin(sessionId) @@ -146,12 +147,15 @@ export function registerStructuredAgentSessionStart( : { aborted: true as const }) } : result - if (!settled.aborted && (startedFor !== undefined || children.length > 0)) { + if ( + !settled.aborted && + (startedFor !== undefined || children.length > 0 || recordUnrequestedFailure) + ) { const session = context.sessions.get(sessionId) if (session) { const record = context.deps.store.getRecord(sessionId) const failure = failedStart(record, settled) - await recordStructuredAgentSessionStartFailure( + const failureItemId = await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: record?.lease.runtimeFence ?? 0 }, { startKey: @@ -163,6 +167,10 @@ export function registerStructuredAgentSessionStart( }, startedFor ?? null ) + if (failureItemId) { + context.subscribers.publish(sessionId, session.journal) + } + return { ...settled, ...(failureItemId ? { failureItemId } : {}) } } } return settled diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index b607856de078..46da79ada9ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -100,7 +100,7 @@ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, failure: StructuredAgentSessionStartFailure, clientMessageId: string | null -): Promise { +): Promise { const selected = session.journal .submissions() .find( @@ -130,6 +130,7 @@ export async function recordStructuredAgentSessionStartFailure( } : {}) }) + return agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) } export function acceptedNoLaterThan( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts index 5b98a6ed0141..e94df69f6c9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts @@ -7,6 +7,7 @@ import type { StructuredAgentSessionEndedEvent } from './structured-agent-sessio import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' export type StructuredAgentSessionStartFlight = { readonly abort: () => void @@ -21,6 +22,44 @@ export class StructuredAgentSessionStartFlights { private readonly flying = new Map() /** Buffer exits before child publication. */ private readonly exits = new Map() + private readonly physical = new Map>; covered: boolean }>() + + observePhysicalExit(sessionId: string, exited: Promise, priorRootProven: boolean): void { + let history = this.physical.get(sessionId) + if (!history) { + history = { owners: new Set(), covered: priorRootProven } + this.physical.set(sessionId, history) + } + const owners = history.owners + owners.add(exited) + const captured = owners + void exited.then( + () => captured.delete(exited), + () => {} + ) + } + + /** Empty coverage survives handle eviction; completed process graphs do not. */ + physicalExit(sessionId: string, retiredWithoutRoot: boolean) { + return { + wait: (signal?: AbortSignal): Promise => { + throwIfSignalAborted(signal) + const history = this.physical.get(sessionId) + if (history ? !history.covered : retiredWithoutRoot) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + // Rejoin the live set after each exact exit; expired callers detach from this observer. + const pending = history?.owners.values().next().value + return pending ? waitForPromiseWithSignal(pending, signal) : Promise.resolve() + }, + current: (): boolean => { + const history = this.physical.get(sessionId) + return history ? history.covered && history.owners.size === 0 : !retiredWithoutRoot + } + } + } get(sessionId: string): StructuredAgentSessionStartFlight | undefined { return this.flying.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index b10f958348bc..fe18c6336498 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -279,10 +279,8 @@ export class StructuredAgentSessionStatusFeed { childWorkOpen = (sessionId: string): boolean => this.ownership.childWorkOpen(sessionId) - captureChildWorkStops = ( - sessionId: string, - handleIds: readonly string[] - ): CapturedChildWorkStops => this.ownership.captureChildWorkStops(sessionId, handleIds) + captureChildWorkStops = (sessionId: string, ids: readonly string[]): CapturedChildWorkStops => + this.ownership.captureChildWorkStops(sessionId, ids) childWorkIncomplete = (sessionId: string): boolean => { try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts index 4d1c0a0b98ba..1a2a2917ac4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts @@ -1,5 +1,4 @@ -// Commit Stop receipt/effects before interrupting; failed acceptance acts on nothing and retries never retarget. - +import type { ChatStopTarget } from './structured-agent-session-chat-stop-target' import { agentSessionFailureSentence } from '../../../shared/agent-session-failure-words' import { refuse, type AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { @@ -23,8 +22,10 @@ import { } from './structured-agent-session-mutation-plans' import type { AgentSessionOperationReceipt, - AgentSessionTurnContext + AgentSessionTurnContext, + TurnOutcome } from './structured-agent-session-turns' +// Commit Stop receipt/effects before interrupting; failed acceptance acts on nothing and retries never retarget. /** Preserve the first committed result, including no-op; later failures answer from that receipt. */ export const STOP_COMMAND_RECEIPT: MutationCommandReceipt = { @@ -151,3 +152,46 @@ export function stopNotSaved( } return { ok: false, refusal: notSaved.refusal() } } + +export async function acceptCapturedChatStop( + callerKey: string, + stoppedSend: boolean | undefined, + ctx: AgentSessionTurnContext, + target: ChatStopTarget +): Promise> { + if (!target.marks) { + const accepted = await acceptStopTarget(ctx) + return accepted.ok ? { ok: true, value: { settled: false, turnId: null } } : accepted + } + const sinceStop = ctx.journal.queuedMessages.userStopInForce()?.sequence + const handed = ctx.journal + .submissions() + .some( + (entry) => + entry.fence === ctx.fence && + (entry.handoverRecorded !== true || entry.handedOverAt !== undefined) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && !entry.recovered)) && + (sinceStop === undefined || (entry.submittedSequence ?? -1) > sinceStop) + ) + const accepted = await acceptChatStop(ctx, { + event: { + reason: 'user-stop', + caller: callerKey, + ...(target.eventTurnId !== null ? { turnId: target.eventTurnId } : {}) + }, + fence: ctx.fence + }) + return accepted.ok + ? { + ok: true, + value: { + settled: + (accepted.value.withdrawn?.length ?? (target.queued ? 1 : 0)) > 0 || + handed || + stoppedSend === true, + turnId: accepted.value.turnId + } + } + : accepted +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-command-selection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-command-selection.ts index b2d7e4d8c21a..a99a08e57576 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-command-selection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-command-selection.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host' import { AgentSessionRefusalError, @@ -7,103 +6,99 @@ import { import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import type { AgentSessionSwitchDeadline, - AgentSessionSwitchResult, - AgentSessionSwitchReceipt + AgentSessionSwitchResult } from '../../../shared/agent-session-switch-execution' -// Commands outlive the read handle they may close themselves. export type SwitchCommand = { invocationId: string generation: string + direction: 'chat' | 'terminal' + workCurrent?: () => boolean settled: boolean - present: (view: Extract) => void - presentation: Promise - shown: Promise - acknowledge: (shown: boolean) => void - opened: Promise - openedResult: (result: AgentSessionSwitchResult) => void + result?: AgentSessionSwitchResult deadline: AgentSessionSwitchDeadline - revokeOnSend: boolean - retain?: () => Promise + retain: () => Promise retentionAllowed: boolean completion: Promise physical?: Promise + physicalRecovery?: Set<() => Promise> cleanupTimer?: ReturnType } +/** The command owns dispatched work even after its viewer gives up. */ export function selectStructuredSessionSwitchCommand(args: { host: StructuredAgentSessionConversationLifetimeHost commands: Map isDisposed: () => boolean id: string invocationId: string + direction: SwitchCommand['direction'] deadline: AgentSessionSwitchDeadline run: () => Promise - revokeOnSend: boolean - phase?: { observe?: boolean; receipt?: AgentSessionSwitchReceipt } onRetained?: (check: () => void) => Promise - allowUncreated: boolean + allowUncreated?: boolean + conversationKey?: string + destinationCurrent?: (result: AgentSessionSwitchResult) => boolean | undefined + onAccepted?: () => void }): SwitchCommand { - const { - host, - commands, - isDisposed, - id, - invocationId, - deadline, - run, - revokeOnSend, - phase, - onRetained, - allowUncreated - } = args + const { host, commands, isDisposed, id, invocationId, direction, deadline, run, onRetained } = + args const { sessions, serialize } = host - const deps = () => host.context().deps deadline.check() - const prior = commands.get(id) + const prior = + (args.conversationKey ? commands.get(args.conversationKey) : undefined) ?? commands.get(id) if (prior) { - if (prior.invocationId !== invocationId && !prior.settled) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'handoffInFlight' + const replay = prior.invocationId === invocationId + const join = prior.direction === direction && !prior.settled + const completed = + !replay && + !join && + prior.direction === direction && + prior.settled && + prior.result?.status === 'completed' + const destinationCurrent = completed ? args.destinationCurrent?.(prior.result!) : undefined + if (completed && destinationCurrent === undefined) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' }) } - if (prior.invocationId === invocationId) { + const reusable = + completed && + destinationCurrent === true && + prior.deadline.remaining() > 0 && + !prior.deadline.signal.aborted && + (!prior.workCurrent || prior.workCurrent()) + if (replay || join || reusable) { + commands.set(id, prior) + if (args.conversationKey) { + commands.set(args.conversationKey, prior) + } return prior } + if (!prior.settled) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'handoffInFlight' + }) + } prior.retentionAllowed = false clearTimeout(prior.cleanupTimer) prior.deadline.revoke() - commands.delete(id) - } - if (phase?.observe || phase?.receipt) { - throw agentSessionRefusalError('agent_session_operation_unknown', { - reason: 'outcomeUnknown' - }) + for (const [alias, current] of commands) { + if (current === prior) { + commands.delete(alias) + } + } } - const session = sessions.get(id) - if (!deps().store.getRecord(id) && !allowUncreated) { + if (!host.context().deps.store.getRecord(id) && !args.allowUncreated) { throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } - let present!: (view: Extract) => void - let acknowledge!: (shown: boolean) => void - let openedResult!: (result: AgentSessionSwitchResult) => void - const presentation = new Promise>( - (resolve) => { - present = resolve - } - ) - const shown = new Promise((resolve) => { - acknowledge = resolve - }) - const opened = new Promise((resolve) => { - openedResult = resolve - }) + args.onAccepted?.() const command: SwitchCommand = { invocationId, - generation: randomUUID(), + generation: invocationId, + direction, settled: false, deadline, - revokeOnSend, retentionAllowed: true, retain: async () => { const check = () => { @@ -116,52 +111,49 @@ export function selectStructuredSessionSwitchCommand(args: { check() await onRetained?.(check) }, - present, - acknowledge, - presentation, - shown, - opened, - openedResult, completion: Promise.resolve().then(async () => { - if (deps().store.getRecord(id)) { + if (host.context().deps.store.getRecord(id)) { await serialize(id, async () => { deadline.check() - const opened = await host.open(id) + const session = await host.open(id) deadline.check() - if (opened) { - opened.switchCommand = command + if (session) { + session.switchCommand = command } }) } try { const result = await run() - if (revokeOnSend && (result.status === 'refused' || result.status === 'uncertain')) { - await command.retain?.() + command.result = result + if (result.status !== 'completed') { + await command.retain() } return result } catch (error) { - if (revokeOnSend) { - await command.retain?.().catch(() => {}) - } + await command.retain().catch(() => {}) throw error } }) } commands.set(id, command) + if (args.conversationKey) { + commands.set(args.conversationKey, command) + } const retainOnExpiry = () => { - if (revokeOnSend) { - void command.retain?.().catch(() => {}) - } + void command.retain().catch(() => {}) } deadline.signal.addEventListener('abort', retainOnExpiry, { once: true }) + const session = sessions.get(id) if (session) { session.switchCommand = command } host.context().publishStatus?.(id) void command.completion - .finally(() => { + .finally(async () => { + await command.physical command.settled = true deadline.dispose() + deadline.signal.removeEventListener('abort', retainOnExpiry) for (const [key, current] of commands) { if (current === command && sessions.get(key)?.switchCommand === command) { delete sessions.get(key)!.switchCommand @@ -173,15 +165,12 @@ export function selectStructuredSessionSwitchCommand(args: { } command.cleanupTimer = setTimeout( () => { - void Promise.resolve(command.physical).then(() => { - command.retentionAllowed = false - deadline.signal.removeEventListener('abort', retainOnExpiry) - for (const [key, current] of commands) { - if (current === command) { - commands.delete(key) - } + command.retentionAllowed = false + for (const [key, current] of commands) { + if (current === command) { + commands.delete(key) } - }) + } }, Math.max(1, deadline.remaining()) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-commands.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-commands.ts index fc02bba47fbc..a80e31bf8049 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-commands.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-commands.ts @@ -2,8 +2,7 @@ import type { StructuredAgentSessionConversationLifetimeHost } from './structure import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionSwitchDeadline, - AgentSessionSwitchResult, - AgentSessionSwitchReceipt + AgentSessionSwitchResult } from '../../../shared/agent-session-switch-execution' import { selectStructuredSessionSwitchCommand, @@ -14,21 +13,15 @@ export function createStructuredAgentSessionSwitchCommands( host: StructuredAgentSessionConversationLifetimeHost, isDisposed: () => boolean ) { - const { serialize } = host - const deps = () => host.context().deps const commands = new Map() - const unsubscribeAcceptance = deps().store.onAcceptedSend((id) => { + const unsubscribe = host.context().deps.store.onAcceptedSend((id) => { const command = commands.get(id) - if (!command?.revokeOnSend || command.settled) { - return + if (command?.workCurrent && !command.settled && !command.workCurrent()) { + command.deadline.revoke( + agentSessionRefusalError('agent_session_operation_invalid', { reason: 'messagesUnsettled' }) + ) } - command.deadline.revoke( - agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'messagesUnsettled' - }) - ) }) - return { controls: { runSwitch: async ( @@ -36,64 +29,49 @@ export function createStructuredAgentSessionSwitchCommands( invocationId: string, deadline: AgentSessionSwitchDeadline, run: () => Promise, - revokeOnSend = true, - phase?: { - observe?: boolean - receipt?: AgentSessionSwitchReceipt - }, - onRetained?: (check: () => void) => Promise, - allowUncreated = false, - onConverged?: (generation: string) => void + options: { + conversationKey?: string + direction: SwitchCommand['direction'] + onRetained?: (check: () => void) => Promise + allowUncreated?: boolean + destinationCurrent?: (result: AgentSessionSwitchResult) => boolean | undefined + onAccepted?: () => void + } ): Promise => { - const selected = await deadline.wait( - serialize(id, async () => { - return selectStructuredSessionSwitchCommand({ - host, - commands, - isDisposed, - id, - invocationId, - deadline, - run, - revokeOnSend, - phase, - onRetained, - allowUncreated - }) + const selected = await deadline + .wait( + host.serialize(options.conversationKey ?? id, async () => + selectStructuredSessionSwitchCommand({ + host, + commands, + isDisposed, + id, + invocationId, + deadline, + run, + ...options + }) + ) + ) + .catch((error: unknown) => { + deadline.dispose() + throw error }) - ) if (selected.deadline !== deadline) { deadline.dispose() } - if (phase?.receipt) { - if (phase.receipt.generation !== selected.generation) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'handoffInFlight' - }) - } - if (phase.receipt.converged) { - onConverged?.(selected.generation) - return { status: 'completed', sessionId: id } - } - if (phase.receipt.retained) { - selected.deadline.revoke( - agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'messagesUnsettled' - }) - ) - await selected.retain?.() - return { status: 'completed', sessionId: id, sourceRetained: true } - } - selected.deadline.check() - selected.acknowledge(phase.receipt.shown) - return Promise.race([selected.completion, selected.opened]) + try { + return await selected.completion + } catch (error) { + // Each ordinary retry drives the original receiver join, never another Stop/create. + void Promise.all( + [...(selected.physicalRecovery ?? [])].map((recover) => recover()) + ).catch(() => {}) + throw error } - return phase?.observe - ? selected.completion - : Promise.race([selected.completion, selected.presentation]) }, adoptSwitch: (from: string, to: string) => - serialize(to, async () => { + host.serialize(to, async () => { const command = commands.get(from) const prior = commands.get(to) command?.deadline.check() @@ -104,58 +82,50 @@ export function createStructuredAgentSessionSwitchCommands( } commands.set(to, command) }), - switchGeneration: (id: string) => commands.get(id)?.generation, - retainSwitch: async (id: string) => { - await commands.get(id)?.retain?.() - }, - presentSwitch: async ( - id: string, - view: Omit< - Extract, - 'status' | 'generation' - > - ) => { + guardSwitchWork: (id: string, current: () => boolean) => { const command = commands.get(id) - if (!command) { - throw agentSessionRefusalError('agent_session_operation_expired', { - reason: 'operationExpired' - }) - } - command.deadline.check() - command.present({ ...view, status: 'present', generation: command.generation }) - if (!(await command.deadline.wait(command.shown))) { - throw agentSessionRefusalError('agent_session_operation_unknown', { - reason: 'tabUnconfirmed' - }) + if (command) { + command.workCurrent = current } - command.deadline.check() }, - openedSwitch: (id: string, result: AgentSessionSwitchResult) => - commands.get(id)?.openedResult(result), + switchGeneration: (id: string) => commands.get(id)?.generation, + retainSwitch: async (id: string) => { + await commands.get(id)?.retain() + }, switchExecuting: (id: string) => Boolean(commands.get(id) && !commands.get(id)?.settled), - trackSwitchPhysical: (id: string, work: Promise): Promise => { + trackSwitchPhysical: ( + id: string, + work: Promise, + recover?: () => Promise + ): Promise => { const command = commands.get(id) if (command) { - command.physical = work.then( - () => {}, - () => {} - ) + command.physical = Promise.allSettled([command.physical, work]).then(() => {}) + if (recover) { + const recoveries = (command.physicalRecovery ??= new Set()) + recoveries.add(recover) + void work.finally(() => recoveries.delete(recover)).catch(() => {}) + } } return work }, waitSwitchPhysical: async (id: string): Promise => { - await commands.get(id)?.physical + const command = commands.get(id) + await Promise.all([...(command?.physicalRecovery ?? [])].map((recover) => recover())) + await command?.physical } }, - revoke: (id: string) => { + revoke: (id: string): boolean => { const command = commands.get(id) - if (command) { - command.retentionAllowed = false - command.deadline.revoke() + if (!command) { + return false } + command.retentionAllowed = false + command.deadline.revoke() + return true }, dispose: () => { - unsubscribeAcceptance() + unsubscribe() for (const command of commands.values()) { command.retentionAllowed = false clearTimeout(command.cleanupTimer) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import-coverage.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import-coverage.ts new file mode 100644 index 000000000000..e82a8b489eee --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import-coverage.ts @@ -0,0 +1,140 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentType } from '../../../shared/agent-status-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' +import { createClaudeTranscriptItemReader } from '../../claude/claude-transcript-item-reader' +import { matchAcpHistoryTurns, type AcpHistoryCandidate } from '../../acp/acp-history-turn-matching' +import { acpNamedTextKey } from '../../acp/acp-turn-messages' +import { GROK_ACP_DIALECT } from '../../acp/acp-dialects/grok-dialect' +import { OPENCODE_ACP_DIALECT } from '../../acp/acp-dialects/opencode-dialect' +import { createLegacyProviderTimelineIdentityScheme } from '../agent-session-timeline/provider-timeline-identity' +import { usableJournalHistoryOrigin } from '../agent-session-journal/journal-history-origin' + +/** Use the ordinary native-history coverage rules before appending disk history at rest. */ +export function switchTranscriptNativeCoverage(input: { + agent: AgentType + providerSessionId: string + journal: AgentSessionJournal + items: readonly JournalReplacementItem[] + check: () => void +}): ReadonlySet { + const covered = new Set() + if (!['claude', 'grok', 'opencode', 'opencode2'].includes(input.agent)) { + return covered + } + const snapshot = input.journal.snapshot() + const segments: JournalReplacementItem[][] = [] + for (const item of input.items) { + if (item.body.kind === 'message' && item.body.role === 'user') { + segments.push([]) + } + segments.at(-1)?.push(item) + } + if (input.agent === 'claude') { + const reader = createClaudeTranscriptItemReader(input.journal, snapshot.sessionId) + for (const segment of segments) { + const user = segment[0] + if (user.identity.provider !== 'claude') { + continue + } + const key = agentJournalItemKey(user.identity) + const native = reader.saved(key) ?? reader.localPrompt(user.identity.uuid) + if (native && native.itemId !== key) { + segment.forEach((item) => covered.add(agentJournalItemKey(item.identity))) + } + } + return covered + } + const scope = { + sessionId: snapshot.sessionId, + providerSessionId: input.providerSessionId, + agent: input.agent, + identity: input.agent === 'grok' ? ('prompt-id' as const) : ('user-message-id' as const), + dialect: input.agent === 'grok' ? GROK_ACP_DIALECT : OPENCODE_ACP_DIALECT + } + const scheme = createLegacyProviderTimelineIdentityScheme(scope) + const recordId = (item: JournalReplacementItem) => + item.identity.provider === 'legacy' ? item.identity.recordId.replace(/^opencode:/, '') : '' + const candidates: AcpHistoryCandidate[] = segments.flatMap((segment) => { + const user = segment[0] + if (user.body.kind !== 'message') { + return [] + } + const turn = recordId(user) + if (!turn) { + return [] + } + return [ + { + source: { + turn, + user: { + blocks: user.body.blocks, + boundary: { method: 'session/update', params: {} } + }, + textOnly: user.body.blocks.every((block) => block.type === 'text'), + observations: [], + messageIds: segment.map(recordId), + startedAt: user.observedAt ?? null + }, + items: segment.map((item) => ({ + kind: 'item' as const, + identity: scheme.item({ + namespace: scope.providerSessionId, + key: { + source: 'provider', + value: acpNamedTextKey( + recordId(item).replace(/:reasoning(?::\d+)?$/, ''), + item.body.kind === 'message' && item.body.role === 'reasoning' + ? 'reasoning' + : 'assistant' + ) + }, + family: 'item', + thread: scope.providerSessionId + }), + body: item.body, + turnScope: { kind: 'thread' as const } + })) + } + ] + }) + const origin = usableJournalHistoryOrigin(input.journal.historyOrigin()) + const handle = origin?.providerHandle + const importedAt = + origin && + handle?.kind === 'opaque' && + handle.agent === input.agent && + handle.value === input.providerSessionId + ? origin.importedAt + : null + const requests = new Set( + input.journal + .submissions() + .filter((send) => send.handedOverAt !== undefined || send.handoverRecorded !== true) + .map((send) => `prompt:${send.clientMessageId}`) + ) + const { kind } = matchAcpHistoryTurns( + scope, + { + items: new Map( + snapshot.items + .filter( + (item) => + importedAt !== null || input.journal.itemProvenance(item.itemId).ownerFence !== 0 + ) + .map((item) => [item.itemId, item]) + ) + }, + candidates, + importedAt, + requests, + input.check + ) + for (const segment of segments) { + if (kind.get(recordId(segment[0])) === 'native') { + segment.forEach((item) => covered.add(agentJournalItemKey(item.identity))) + } + } + return covered +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.test.ts new file mode 100644 index 000000000000..bce230547583 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.test.ts @@ -0,0 +1,425 @@ +import { mkdtemp, rm, writeFile, readFile, copyFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { writeCodexHistoryFixture } from '../agent-session-journal/codex-history-test-fixture' +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat/transcript/native-chat-cut-turn-notice' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { catchUpStructuredSessionAtRest } from './structured-agent-session-at-rest-catch-up' +import { + captureSwitchStoppedCliTurn, + catchUpSwitchedCliTranscript +} from './structured-agent-session-switch-import' + +vi.mock('../../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { + const query = await import('../transcript-opencode-sqlite-query') + return { + readOpenCodeTranscriptPageViaWorker: async ( + args: Parameters[0] + ) => query.readOpenCodeTranscriptPage(args), + readOpenCodeTranscriptSignalViaWorker: async (args: { dbPath: string; sessionId: string }) => + query.readOpenCodeTranscriptSignal(args.dbPath, args.sessionId) + } +}) +const journals = createTrackedJournalOpener() +let root: string +let deadline: ReturnType +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-switch-import-')) + deadline = agentSessionSwitchDeadline() +}) +afterEach(async () => { + deadline.dispose() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) +function options() { + return { + stateDirectory: root, + identity: { + sessionId: 'native', + workspaceId: 'folder', + hostId: 'local', + agent: 'codex' as const, + providerHandle: codexProviderHandle('019aef12-1234-7000-8000-123456789abc') + } + } +} +const claudeRecording = await readFile( + join(import.meta.dirname, '__fixtures__', 'claude-cli-switch-2.1.302.jsonl'), + 'utf8' +) +const claudeRecordingId: string = JSON.parse(claudeRecording.split('\n')[0]!).sessionId +it.each([ + ['claude', claudeRecordingId], + ['codex', '01a183db-b8d6-7170-af39-e004651c1e1e'], + ['pi', '01a1841b-2daa-7238-ade8-4b2474dcac14'], + ['grok', '01a105db-937d-7ec0-b1d1-5396ae618abf'], + ['opencode', 'ses_e7afa14d9ffernpe5xoa3e21SL'], + ['opencode2', 'ses_e7bc5544cffeP7rTuOdvcklM2n'] +] as const)( + 'settles recorded %s storage durably without replacing native completion or duplicating imported tools', + async (agent, recordedId) => { + const fixtures = join(import.meta.dirname, '__fixtures__', 'recorded-native-switch', agent) + const sqlite = agent === 'opencode' || agent === 'opencode2' + const filePath = join(root, sqlite ? 'native.db' : 'native.jsonl') + const source = { agent, sessionId: agent === 'pi' ? filePath : recordedId, filePath } + async function install(completed: boolean) { + if (agent === 'claude') { + const rows = claudeRecording.trimEnd().split('\n') + expect(rows).toHaveLength(4) + // Fixture truncation at genuine tool use, not a recording of Stop or exit. + await writeFile(filePath, completed ? claudeRecording : `${rows.slice(0, 2).join('\n')}\n`) + } else if (agent === 'grok') { + const directory = join(fixtures, completed ? 'completed' : 'interrupted-prefix') + await copyFile(join(directory, 'chat_history.jsonl'), filePath) + await copyFile(join(directory, 'events.jsonl'), join(root, 'events.jsonl')) + } else if (agent === 'pi') { + const recording = await readFile(join(fixtures, 'completed.jsonl'), 'utf8') + const rows = recording.trimEnd().split('\n') + expect(JSON.parse(rows[0]!).id).toBe(recordedId) + expect(source.sessionId).not.toBe(recordedId) + // Fixture truncation at a genuine tool result; no captured Stop or physical exit. + await writeFile(filePath, completed ? recording : `${rows.slice(0, 6).join('\n')}\n`) + } else { + await copyFile( + join( + fixtures, + `${completed ? 'completed' : 'recorded-prefix'}.${sqlite ? 'db' : 'jsonl'}` + ), + filePath + ) + } + } + const recordedOptions = { + ...options(), + identity: { + ...options().identity, + agent, + providerHandle: + agent === 'codex' + ? codexProviderHandle(recordedId) + : agent === 'claude' + ? claudeProviderHandle(recordedId, null) + : null + } + } + async function imported(sessionId: string) { + const journal = await journals.open({ + ...recordedOptions, + identity: { ...recordedOptions.identity, sessionId } + }) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent, + sessionId: source.sessionId, + fence: 1, + options: { filePath } + }) + expect(result).toMatchObject({ ok: true, replaced: true }) + return journal + } + const turns = (journal: AgentSessionJournal) => + journal.snapshot().items.flatMap(({ body }) => (body.kind === 'turn' ? [body] : [])) + const blocks = (journal: AgentSessionJournal) => + journal + .snapshot() + .items.flatMap(({ body }) => + body.kind === 'message' + ? body.blocks.map((block) => block.type) + : body.kind === 'tool-call' + ? ['tool-call'] + : [] + ) + await install(false) + const historical = await imported('historical-recording') + await catchUpSwitchedCliTranscript({ journal: historical, source, fence: 1, deadline }) + expect(turns(historical)).toEqual([]) + const stopped = await captureSwitchStoppedCliTurn(source, true, deadline) + expect(stopped).toMatchObject({ sessionId: source.sessionId }) + let journal = await imported('switched-recording') + const content = journal.snapshot().items + expect( + content.filter(({ body }) => body.kind === 'message' && body.role === 'user') + ).toHaveLength(1) + expect(blocks(journal)).toContain('tool-call') + await catchUpSwitchedCliTranscript({ journal, source, fence: 1, deadline, stopped }) + expect(turns(journal)).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(journal.snapshot().items.filter(({ body }) => body.kind !== 'turn')).toEqual(content) + const interrupted = journal.snapshot() + const [turn] = turns(journal) + const verdict = agentTurnVerdict({ state: turn!.state, outcome: turn!.outcome ?? null }) + expect(verdict).toBe('cancellation') + expect( + agentVerdictDisplayMark({ + state: 'done', + mainAgent: { state: 'done', outcome: verdict ?? undefined } + }) + ).toBe('interrupted') + expect(withNativeChatCutTurnNotices(interrupted.items)).toBe(interrupted.items) + await journal.close() + journal = await journals.open({ + ...recordedOptions, + identity: { ...recordedOptions.identity, sessionId: 'switched-recording' } + }) + await catchUpSwitchedCliTranscript({ journal, source, fence: 1, deadline, stopped }) + expect(journal.snapshot()).toEqual(interrupted) + if (agent === 'claude') { + const prefix = await readFile(filePath, 'utf8') + const assistant = JSON.parse(prefix.trimEnd().split('\n').at(-1)!) + expect(typeof assistant.uuid).toBe('string') + await writeFile( + filePath, + `${prefix}${JSON.stringify({ + type: 'user', + uuid: 'interrupted-cli-turn', + sessionId: recordedId, + interruptedMessageId: assistant.uuid, + timestamp: new Date(Date.parse(assistant.timestamp) + 1).toISOString(), + message: { + role: 'user', + content: [{ type: 'text', text: '[Request interrupted by user]' }] + } + })}\n` + ) + const historyWithMarker = await imported('historical-interruption-marker') + await catchUpSwitchedCliTranscript({ journal: historyWithMarker, source, fence: 1, deadline }) + const [historicalTurn] = turns(historyWithMarker) + expect(historicalTurn).toMatchObject({ state: 'interrupted', turnId: stopped!.turnId }) + expect(historicalTurn!.outcome).toBeUndefined() + expect(agentTurnVerdict({ state: historicalTurn!.state, outcome: null })).toBe('interruption') + const historyItems = historyWithMarker.snapshot().items + expect(withNativeChatCutTurnNotices(historyItems)).toHaveLength(historyItems.length + 1) + const stoppedWithMarker = await imported('stopped-interruption-marker') + await catchUpSwitchedCliTranscript({ + journal: stoppedWithMarker, + source, + fence: 1, + deadline, + stopped + }) + expect(turns(stoppedWithMarker)).toEqual([ + expect.objectContaining({ + state: 'interrupted', + turnId: stopped!.turnId, + outcome: 'cancellation' + }) + ]) + const stoppedItems = stoppedWithMarker.snapshot().items + expect(withNativeChatCutTurnNotices(stoppedItems)).toBe(stoppedItems) + const stoppedSnapshot = stoppedWithMarker.snapshot() + await catchUpSwitchedCliTranscript({ + journal: stoppedWithMarker, + source, + fence: 1, + deadline, + stopped + }) + expect(stoppedWithMarker.snapshot()).toEqual(stoppedSnapshot) + } + if (agent === 'codex') { + const prefix = await readFile(filePath, 'utf8') + const started = prefix + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + .find((row) => row.type === 'event_msg' && row.payload.type === 'task_started') + expect(started.payload.turn_id).toBe(stopped!.turnId) + await writeFile( + filePath, + `${prefix}${JSON.stringify({ + type: 'event_msg', + timestamp: new Date(Date.parse(started.timestamp) + 1).toISOString(), + payload: { type: 'turn_aborted', turn_id: stopped!.turnId } + })}\n` + ) + const canonicalTurn = agentJournalItemKey({ + provider: 'legacy', + agent, + sessionId: recordedId, + recordId: `turn-lifecycle:${stopped!.turnId}` + }) + for (const personalStop of [false, true]) { + const nativeOptions = { + ...recordedOptions, + identity: { + ...recordedOptions.identity, + sessionId: personalStop ? 'at-rest-stopped' : 'at-rest-historical' + } + } + let retained = await journals.open(nativeOptions) + await retained.appendItem( + { provider: 'codex', threadId: recordedId, turnId: 'prior-native-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'prior-native-turn', state: 'completed', outcome: 'success' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const nativeItems = retained.snapshot().items + expect(turns(retained)).toHaveLength(1) + expect(retained.item(canonicalTurn)).toBeNull() + await catchUpStructuredSessionAtRest({ + journal: retained, + threadId: recordedId, + filePath, + fence: 1, + deadline, + stoppedTurnId: personalStop ? stopped!.turnId : undefined, + check: deadline.check + }) + const firstEnd = retained.item(canonicalTurn) + expect(firstEnd?.body).toEqual({ + kind: 'turn', + turnId: stopped!.turnId, + state: 'interrupted', + ...(personalStop ? { outcome: 'cancellation' } : {}), + completedAt: Date.parse(started.timestamp) + 1 + }) + expect(retained.snapshot().items.slice(0, nativeItems.length)).toEqual(nativeItems) + await catchUpSwitchedCliTranscript({ + journal: retained, + source, + fence: 1, + deadline, + stopped: personalStop ? stopped : undefined + }) + expect(retained.item(canonicalTurn)).toEqual(firstEnd) + const ended = turns(retained).at(-1)! + const verdict = agentTurnVerdict({ state: ended.state, outcome: ended.outcome ?? null }) + expect(verdict).toBe(personalStop ? 'cancellation' : 'interruption') + expect( + agentVerdictDisplayMark({ + state: 'done', + mainAgent: { state: 'done', outcome: verdict! } + }) + ).toBe(personalStop ? 'interrupted' : 'failed') + const snapshot = retained.snapshot() + if (personalStop) { + expect(withNativeChatCutTurnNotices(snapshot.items)).toBe(snapshot.items) + } else { + expect(withNativeChatCutTurnNotices(snapshot.items)).toHaveLength( + snapshot.items.length + 1 + ) + } + await retained.close() + retained = await journals.open(nativeOptions) + await catchUpStructuredSessionAtRest({ + journal: retained, + threadId: recordedId, + filePath, + fence: 1, + deadline, + stoppedTurnId: stopped!.turnId, + check: deadline.check + }) + await catchUpSwitchedCliTranscript({ + journal: retained, + source, + fence: 1, + deadline, + stopped + }) + expect(retained.canonicalItemId(canonicalTurn)).toBe(canonicalTurn) + expect(retained.snapshot()).toEqual(snapshot) + } + } + await install(true) + const completed = await imported('completed-recording') + await catchUpSwitchedCliTranscript({ journal: completed, source, fence: 1, deadline, stopped }) + expect(turns(completed)).toEqual([expect.objectContaining({ state: 'completed' })]) + expect(blocks(completed)).toContain('tool-result') + const final = completed.snapshot() + await catchUpSwitchedCliTranscript({ journal: completed, source, fence: 1, deadline, stopped }) + expect(completed.snapshot()).toEqual(final) + } +) + +it.each(['adopted-active', 'retained-archive'] as const)( + 'keeps account-qualified paginated ancestry through capture and final refresh: %s', + async (route) => { + const sessions = join(root, 'account', 'sessions') + const archive = join(root, 'account', 'archived_sessions') + await mkdir(sessions, { recursive: true }) + await mkdir(archive) + const parentId = '01a17c7f-6f58-7a73-b87a-a8702ff2da17' + const childId = '01a17c81-30d1-72f0-b84e-002ddfe6fd47' + const parent = await writeCodexHistoryFixture( + route === 'retained-archive' ? archive : sessions, + parentId, + ['prior completed'] + ) + const child = await writeCodexHistoryFixture(sessions, childId, ['current'], parent.position) + const source = { + agent: 'codex' as const, + sessionId: childId, + filePath: child.filePath, + ...(route === 'adopted-active' + ? { + source: { providerSessionId: childId, workspaceCwd: root, conversationStore: sessions } + } + : { codexHome: join(root, 'account') }) + } + const stopped = await captureSwitchStoppedCliTurn(source, true, deadline) + expect(stopped).toMatchObject({ sessionId: childId }) + const journal = await journals.open({ + ...options(), + identity: { ...options().identity, providerHandle: codexProviderHandle(childId) } + }) + expect( + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'codex', + sessionId: childId, + fence: 1, + options: { filePath: child.filePath, codexSessionsDirs: [sessions] } + }) + ).toMatchObject({ ok: true, replaced: true }) + const messages = journal.snapshot().items + expect( + messages.filter(({ body }) => body.kind === 'message').map(({ body }) => body) + ).toMatchObject([ + { blocks: [{ text: 'prior completed' }] }, + { blocks: [{ text: 'prior completed answer' }] }, + { blocks: [{ text: 'current' }] }, + { blocks: [{ text: 'current answer' }] } + ]) + await catchUpSwitchedCliTranscript({ journal, source, fence: 1, deadline, stopped }) + expect(journal.snapshot().items.filter(({ body }) => body.kind !== 'turn')).toEqual(messages) + expect( + journal + .snapshot() + .items.filter(({ body }) => body.kind === 'turn') + .map(({ body }) => body) + ).toEqual([expect.objectContaining({ state: 'interrupted', turnId: stopped!.turnId })]) + const refreshed = journal.snapshot() + await catchUpSwitchedCliTranscript({ journal, source, fence: 1, deadline }) + expect(journal.snapshot()).toEqual(refreshed) + await expect( + captureSwitchStoppedCliTurn( + { + ...source, + source: undefined, + codexHome: join(root, 'wrong-account') + }, + true, + deadline + ) + ).rejects.toMatchObject({ + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' } + } + }) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.ts new file mode 100644 index 000000000000..4a80b0cb690c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-import.ts @@ -0,0 +1,187 @@ +import { switchTranscriptNativeCoverage } from './structured-agent-session-switch-import-coverage' +import { + decodePiHistoryClaim, + readPiHistoryJournalView, + usablePiHistoryClaim +} from '../../pi/rpc-history-journal-view' +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import type { AgentType } from '../../../shared/agent-status-types' +import { getSystemCodexHomePath } from '../../codex/codex-home-paths' +import type { AdoptedTranscriptSource } from '../agent-session-journal/adopted-transcript-source' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { prepareLegacyTranscriptImport } from '../agent-session-journal/journal-legacy-import' +import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' + +export type SwitchTranscriptSource = { + agent: AgentType + sessionId: string + filePath: string + codexHome?: string | null + source?: AdoptedTranscriptSource +} + +export type SwitchStoppedCliTurn = { + sessionId: string + userItemKey: string + turnId: string +} + +async function prepare( + source: SwitchTranscriptSource, + deadline: AgentSessionSwitchDeadline, + piAfterEntry?: string +) { + deadline.check() + const prepared = await prepareLegacyTranscriptImport({ + agent: source.agent, + piAfterEntry, + sessionId: source.sessionId, + options: { + filePath: source.filePath, + source: source.source, + ...(source.agent === 'codex' && source.codexHome !== undefined + ? { codexSessionsDirs: [join(source.codexHome ?? getSystemCodexHomePath(), 'sessions')] } + : {}) + } + }) + deadline.check() + if (!prepared.ok) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + return prepared +} + +function lastUser(items: readonly JournalReplacementItem[]) { + return items.findLast((item) => item.body.kind === 'message' && item.body.role === 'user') +} + +/** This capture is used only with an explicit Stop and positive exit of its exact CLI. */ +export async function captureSwitchStoppedCliTurn( + source: SwitchTranscriptSource, + working: boolean, + deadline: AgentSessionSwitchDeadline +): Promise { + if (!working) { + return undefined + } + const prepared = await prepare(source, deadline) + const user = lastUser(prepared.items) + if (!user || (prepared.latestTurn && prepared.latestTurn.state !== 'working')) { + return undefined + } + const key = agentJournalItemKey(user.identity) + return { + sessionId: source.sessionId, + userItemKey: key, + turnId: prepared.latestTurn?.turnId ?? key + } +} + +/** Append through the existing journal owner: native submissions and imported identities survive. */ +export async function catchUpSwitchedCliTranscript(input: { + journal: AgentSessionJournal + source: SwitchTranscriptSource + fence: number + deadline: AgentSessionSwitchDeadline + stopped?: SwitchStoppedCliTurn + check?: () => void + resumeCursor?: string +}) { + let piAfterEntry: string | undefined + if (input.source.agent === 'pi') { + const view = readPiHistoryJournalView(input.journal, new Set([input.source.filePath])) + const claim = usablePiHistoryClaim(decodePiHistoryClaim(input.resumeCursor), view) + if (view.ownWorkSequence > (claim?.sequence ?? 0)) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + piAfterEntry = claim?.entry + } + const prepared = await prepare(input.source, input.deadline, piAfterEntry) + const items = prepared.items + const user = lastUser(items) + const userKey = user && agentJournalItemKey(user.identity) + const latest = prepared.latestTurn + const interrupted = + input.stopped?.sessionId === input.source.sessionId && + input.stopped.userItemKey === userKey && + (!latest || + (latest.turnId === input.stopped.turnId && + (latest.state === 'working' || + (latest.state === 'interrupted' && latest.outcome === undefined)))) + const covered = switchTranscriptNativeCoverage({ + agent: input.source.agent, + providerSessionId: input.source.sessionId, + journal: input.journal, + items, + check: input.check ?? input.deadline.check + }) + const additions = + input.source.agent === 'codex' + ? [] + : items.filter((item) => !covered.has(agentJournalItemKey(item.identity))) + const ended = + latest && latest.state !== 'working' ? { ...latest, state: latest.state } : undefined + const turnId = interrupted ? input.stopped?.turnId : ended?.turnId + if (turnId && userKey && !covered.has(userKey)) { + additions.push({ + identity: { + provider: 'legacy', + agent: input.source.agent, + sessionId: input.source.sessionId, + recordId: `turn-lifecycle:${turnId}` + }, + ownerFence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + body: { + kind: 'turn', + turnId, + userItemId: userKey, + state: interrupted ? 'interrupted' : ended!.state, + ...(interrupted + ? { outcome: 'cancellation' as const } + : ended?.outcome + ? { outcome: ended.outcome } + : {}), + ...(ended?.timestamp == null ? {} : { completedAt: ended.timestamp }) + } + }) + } + const digest = createHash('sha256').update(JSON.stringify(additions)).digest('hex') + await input.journal.appendPlannedLifecycleBatch({ + settlementId: `switch-import:${digest}`, + fence: input.fence, + plan: () => { + input.deadline.check() + input.check?.() + return { + dispatches: [], + mutations: additions + .filter((item) => { + const current = input.journal.item( + input.journal.canonicalItemId(agentJournalItemKey(item.identity)) + ) + return ( + !current || + (item.body.kind === 'turn' && + current.body.kind === 'turn' && + current.body.state === 'running') + ) + }) + .map((item) => ({ + kind: 'item' as const, + ...item, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + })) + } + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-source.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-source.ts new file mode 100644 index 000000000000..79a09f8a23ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-source.ts @@ -0,0 +1,224 @@ +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import { randomUUID } from 'node:crypto' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentSessionLeasePossiblyWriting } from '../../../shared/agent-session-lease-adjudication' +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' +import { + catchUpSwitchedCliTranscript, + type SwitchStoppedCliTurn, + type SwitchTranscriptSource +} from './structured-agent-session-switch-import' +import { catchUpStructuredSessionAtRest } from './structured-agent-session-at-rest-catch-up' +import type * as HostTypes from './structured-agent-session-host-types' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' +type SwitchSourceContext = Pick< + StructuredAgentSessionHost, + 'deps' | 'cancel' | 'switchGeneration' | 'guardSwitchWork' +> & { + now: () => number + startFlights: Pick + lifetime: Pick + sessions: StructuredAgentSessionConversationLifetimeHost['sessions'] + serialize: StructuredAgentSessionConversationLifetimeHost['serialize'] + subscribers: { + publish: ( + sessionId: string, + journal: HostTypes.StructuredAgentSessionHostSession['journal'] + ) => void + } +} +export function structuredAgentSessionSwitchSource(context: SwitchSourceContext) { + return { + captureSwitchWork: (id: string, deadline: AgentSessionSwitchDeadline) => { + const count = context.deps.store.admittedSendCount(id) + const command = context.switchGeneration(id) + return () => + !deadline.signal.aborted && + deadline.remaining() > 0 && + context.deps.store.admittedSendCount(id) === count && + context.switchGeneration(id) === command + }, + stopForSwitch: async ( + sessionId: string, + caller: HostTypes.StructuredAgentSessionCaller, + deadline: AgentSessionSwitchDeadline + ) => { + deadline.check() + await context.lifetime.conversation(sessionId) + deadline.check() + let child = context.sessions.get(sessionId)?.child ?? null + let admitted = context.deps.store.admittedSendCount(sessionId) + let accepted = false + const command = context.switchGeneration(sessionId) + const workCurrent = () => + !deadline.signal.aborted && + deadline.remaining() > 0 && + context.deps.store.admittedSendCount(sessionId) === admitted && + context.switchGeneration(sessionId) === command + context.guardSwitchWork(sessionId, workCurrent) + const record = context.deps.store.getRecord(sessionId) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + let settleWorker: ((proven: boolean) => void) | undefined + try { + settleWorker = context.deps.captureSessionSwitchStop?.(sessionId) + } catch { + context.deps.logger.warn('capturing worker Stop failed', { + scope: 'session-switch', + sessionId + }) + } + let proven = false + try { + const answer = await context.cancel(caller, { + envelope: { + sessionId, + clientOperationId: createStructuredAgentSessionOperationId(randomUUID, context.now()), + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId, + fields: {} + }) + }, + onAccepted: (captured) => { + child = captured + admitted = context.deps.store.admittedSendCount(sessionId) + accepted = true + }, + waitForShutdown: true + }) + if (!answer.ok) { + throw new AgentSessionRefusalError(answer.refusal) + } + const source = await context.serialize(sessionId, async () => { + deadline.check() + const current = context.sessions.get(sessionId)?.child + if ( + context.deps.store.admittedSendCount(sessionId) !== admitted || + context.switchGeneration(sessionId) !== command || + (current && current !== child) + ) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + if (current) { + try { + await context.lifetime.stopAgent( + sessionId, + accepted ? { recorded: 'user-stop' } : { cause: 'user-close' } + ) + } catch { + context.deps.logger.warn('switch source exit was not confirmed', { + scope: 'session-switch', + sessionId, + source: 'chat', + exactChild: true + }) + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + } + const lease = context.deps.store.getRecord(sessionId)?.lease + if (!lease || agentSessionLeasePossiblyWriting(lease)) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + const physical = context.startFlights.physicalExit( + sessionId, + lease.deathEvidence?.kind === 'kill-delivered' + ) + return { physical } + }) + // Source completion cannot occupy the serializer or the destination-work barrier. + for (;;) { + await deadline.wait(source.physical.wait(deadline.signal)) + const complete = await context.serialize(sessionId, async () => { + deadline.check() + const current = context.sessions.get(sessionId)?.child + if ( + context.deps.store.admittedSendCount(sessionId) !== admitted || + context.switchGeneration(sessionId) !== command || + (current && current !== child) + ) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + const lease = context.deps.store.getRecord(sessionId)?.lease + if (!lease || agentSessionLeasePossiblyWriting(lease)) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + return source.physical.current() + }) + if (complete) { + break + } + } + proven = true + return () => workCurrent() && source.physical.current() + } finally { + try { + settleWorker?.(proven) + } catch { + context.deps.logger.warn('settling worker Stop failed', { + scope: 'session-switch', + sessionId, + proven + }) + } + } + }, + refreshSwitchTranscript: async ( + sessionId: string, + source: Omit & { + agent?: SwitchTranscriptSource['agent'] + }, + deadline: AgentSessionSwitchDeadline, + stopped?: SwitchStoppedCliTurn, + check: () => void = deadline.check + ) => { + const session = await context.lifetime.conversation(sessionId) + const transcript = { ...source, agent: source.agent ?? session.params.agent } + if (transcript.agent === 'codex') { + await catchUpStructuredSessionAtRest({ + journal: session.journal, + threadId: source.sessionId, + filePath: source.filePath, + fence: context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0, + deadline, + stoppedTurnId: stopped?.turnId, + check + }) + } + await catchUpSwitchedCliTranscript({ + journal: session.journal, + source: transcript, + resumeCursor: agentSessionProviderHandleChainHead( + context.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] + )?.handle.resumeCursor, + fence: context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0, + deadline, + stopped, + check + }) + context.subscribers.publish(sessionId, session.journal) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts new file mode 100644 index 000000000000..559c52f48be2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts @@ -0,0 +1,173 @@ +import { + agentSessionRefusalFromReference, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + operationDidNotRun, + selectStructuredAgentSessionOperationChild +} from './structured-agent-session-operation-child' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { + type StructuredAgentSessionStartFailureCause, + structuredAgentSessionStartFailure, + structuredAgentSessionStartFailureFact, + typedStartRefusal +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' + +// A terminal handing its conversation to the chat that already holds it: the chat's agent starts +// through the one operation start (its provider open imports the terminal's turns), and only that +// same child reaching ready is success. Its ended child's typed failure is the click's refusal. + +export type StructuredAgentSessionSwitchToChatOutcome = + | { ok: true } + | { ok: false; refusal: AgentSessionWireRefusal; failureItemId?: string } + +async function selectSwitchStart( + context: StructuredAgentSessionMutationContext, + sessionId: string, + deadline?: AgentSessionSwitchDeadline +) { + return context.serialize(sessionId, async () => { + deadline?.check() + if (!(await context.openConversation(sessionId))) { + return operationDidNotRun() + } + deadline?.check() + const selection = selectStructuredAgentSessionOperationChild(context, sessionId) + return selection.ok && !selection.child + ? { ...selection, start: context.claimAgent(sessionId, deadline?.check, true) } + : { ...selection, start: null } + }) +} + +export async function openStructuredAgentSessionForSwitch( + context: StructuredAgentSessionMutationContext, + sessionId: string, + deadline?: AgentSessionSwitchDeadline +) { + const selected = await selectSwitchStart(context, sessionId, deadline) + return { + owned: selected.ok && Boolean(selected.start?.ownsStart), + outcome: finishSwitchStart(context, sessionId, selected, deadline) + } +} + +export async function startStructuredAgentSessionForSwitch( + context: StructuredAgentSessionMutationContext, + sessionId: string, + deadline?: AgentSessionSwitchDeadline +): Promise { + return (await openStructuredAgentSessionForSwitch(context, sessionId, deadline)).outcome +} + +async function finishSwitchStart( + context: StructuredAgentSessionMutationContext, + sessionId: string, + selected: Awaited>, + deadline?: AgentSessionSwitchDeadline +): Promise { + if (!selected.ok) { + return { ok: false, refusal: selected.refusal } + } + let { child, readiness } = selected + if (!child) { + const started: StructuredAgentSessionResumeOutcome = await (selected.start?.outcome ?? + operationDidNotRun()) + if (!started.ok) { + const result = started.failure + ? switchStartFailure(context, sessionId, { failure: started.failure }) + : { ok: false as const, refusal: started.refusal } + return { + ...result, + ...(deadline && started.failureItemId ? { failureItemId: started.failureItemId } : {}) + } + } + const selection = selectStructuredAgentSessionOperationChild(context, sessionId) + if (!selection.ok || !selection.child) { + return selection.ok ? operationDidNotRun() : selection + } + child = selection.child + readiness = selection.readiness + } + const outcome = + (await readiness?.wait( + Math.min(STRUCTURED_AGENT_SESSION_START_WAIT_MS, deadline?.remaining() ?? Infinity) + )) ?? 'ready' + const awaited = child + return context.serialize(sessionId, async () => { + return switchStartVerdict(context, sessionId, awaited, outcome, Boolean(deadline)) + }) +} + +/** Under the queue: the awaited child itself is ready, or its own end says why it is not. */ +async function switchStartVerdict( + context: StructuredAgentSessionMutationContext, + sessionId: string, + awaited: StructuredAgentSessionProviderChildIdentity, + outcome: 'ready' | 'ended' | 'timeout', + execution: boolean +): Promise { + const session = context.sessions.get(sessionId) + const child = session?.child + if (outcome === 'ready' && child?.phase === 'ready' && !child.close) { + return sameProviderChild(child, awaited) ? { ok: true } : operationDidNotRun() + } + const ended = session?.lastEndedChild + const failure = + ended && sameProviderChild(ended, awaited) + ? structuredAgentSessionEndedChildFailure(ended) + : null + if (!failure) { + return operationDidNotRun(outcome === 'timeout') + } + const failureItemId = + execution && session + ? await recordStructuredAgentSessionStartFailure( + { + journal: session.journal, + fence: context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + }, + { + startKey: awaited.generation, + ...structuredAgentSessionStartFailure( + failure, + structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)) + ) + }, + null + ) + : undefined + return { + ...switchStartFailure(context, sessionId, failure), + ...(failureItemId ? { failureItemId } : {}) + } +} + +function switchStartFailure( + context: StructuredAgentSessionMutationContext, + sessionId: string, + failure: StructuredAgentSessionStartFailureCause +): StructuredAgentSessionSwitchToChatOutcome { + const fact = structuredAgentSessionStartFailureFact(failure) + const { reason } = structuredAgentSessionStartFailure( + failure, + structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)) + ) + const typed = typedStartRefusal(fact.kind) + const refusal: AgentSessionWireRefusal = typed + ? refuse('agent_session_operation_invalid', { reason: typed }, reason) + : fact.refusal + ? agentSessionRefusalFromReference(fact.refusal, reason) + : refuseUnclassified('agent_session_owner_restart_failed', reason) + return { ok: false, refusal } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index a66804cd1a05..27e9930feefb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -9,6 +9,7 @@ vi.mock('../../../shared/process-table-snapshot-reader', async (importOriginal) ...(await importOriginal()), getFreshShellForegroundSnapshot: physicalSnapshot })) +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,6 +58,10 @@ const acquire = vi.fn(async ({ fence, observedAt: NOW } })) +const open = vi.fn(async () => ({ + reportedOptions: { model: '' } +})) +const closeSession = vi.fn>() const assertNoTerminalWriter = vi.fn(async (_record: AgentSessionRecord) => { if (terminalWriting) { throw agentSessionRefusalError('agent_session_conflict', { @@ -71,6 +76,8 @@ beforeEach(async () => { terminalWriting = false physicalSnapshot.mockReset() acquire.mockClear() + open.mockClear() + closeSession.mockReset().mockResolvedValue(true) assertNoTerminalWriter.mockReset().mockImplementation(async () => { if (terminalWriting) { throw agentSessionRefusalError('agent_session_conflict', { @@ -96,12 +103,12 @@ beforeEach(async () => { state: 'accepted' as const, providerIdentity: { provider: 'codex' as const, threadId: THREAD, turnId: 't', ordinal: 1 } })), - closeSession: vi.fn(async () => true), + closeSession, releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - open: async () => ({ reportedOptions: { model: '' } }) + open }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', @@ -113,6 +120,7 @@ beforeEach(async () => { }) afterEach(async () => { + setStructuredAgentSessionHost(null) await host.flushAllStreamedEvents() closeTestJournalHostDatabase(root) await rm(root, { recursive: true, force: true }) @@ -173,6 +181,8 @@ it('refuses the chat agent start while a terminal writes its conversation, once }) }) +// The switch is idle-only and proves the stop; subsequent sends use ordinary G-acquire. + it('acquires when only a saved Sleep note remains, but refuses an existing unknown terminal', async () => { const saved: SleepingAgentSessionRecord = { paneKey: 'slept', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-work-evidence.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-work-evidence.ts new file mode 100644 index 000000000000..61ad4207c1e9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-work-evidence.ts @@ -0,0 +1,72 @@ +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +export type StructuredAgentSessionWorkEvidence = { + record: Pick | null + ended?: StructuredAgentSessionSeenEnd + revision?: number + replaced?: StructuredAgentSessionReplacedEnd | undefined + /** Absent means the host has no child observation. */ + child?: { fence: number; heldSends?: () => ReadonlySet | undefined } | null +} + +/** Replaced runtimes granted all fences through this boundary. */ +export type StructuredAgentSessionReplacedEnd = { fence: number } + +export type StructuredAgentSessionSeenEnd = { + fence: number + rootGone?: boolean + cause?: string +} + +/** Missing records retain legacy fence-0 behavior; released/ended generations have no live fence. */ +export function structuredAgentSessionLiveFence( + evidence: StructuredAgentSessionWorkEvidence +): number | null { + const record = evidence.record + const lease = record?.ownerObservation + const fence = record?.currentFence + if (!lease) { + return 0 + } + if (lease.claimStatus === 'released') { + return null + } + if ( + structuredAgentSessionReplacedRuntimeEnded( + { runtimeFence: fence ?? 0, claimStatus: lease.claimStatus }, + evidence.replaced + ) || + structuredAgentSessionOwnerUnverifiable(evidence) + ) { + return null + } + const { ended } = evidence + return ended?.rootGone === true && ended.fence === fence ? null : (fence ?? 0) +} + +/** Unreconciled/recovering owners without a local child remain unverifiable, not current work. */ +export function structuredAgentSessionOwnerUnverifiable( + evidence: StructuredAgentSessionWorkEvidence +) { + const record = evidence.record + const lease = record?.ownerObservation + const fence = record?.currentFence + return ( + (lease?.unreconciled === true || lease?.handoffStage === 'recovering') && + evidence.child !== undefined && + evidence.child?.fence !== fence + ) +} + +/** Replaced runtimes lost their pipes; conflicted terminal claims are outside that proof. */ +export function structuredAgentSessionReplacedRuntimeEnded( + lease: Pick, + replaced: StructuredAgentSessionReplacedEnd | undefined +): boolean { + return ( + replaced !== undefined && + lease.runtimeFence <= replaced.fence && + lease.claimStatus !== 'conflicted' + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index 2093e2711c50..a6f3e6c0656e 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -3,7 +3,8 @@ import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { agentSessionOwnerObservationVerdict } from '../../../shared/agent-session-lease-adjudication' +import { agentSessionLeasePossiblyWriting } from '../../../shared/agent-session-lease-adjudication' +export { agentSessionLeasePossiblyWriting } from '../../../shared/agent-session-lease-adjudication' import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' import { keysMayMatch, @@ -54,11 +55,6 @@ export function listStructuredProviderSessionOwnership( }) } -/** Fail closed: only a lease released on proof its owner exited can't be writing. */ -export function agentSessionLeasePossiblyWriting(lease: AgentSessionOwnerObservation): boolean { - return agentSessionOwnerObservationVerdict(lease) !== 'exited' -} - /** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */ export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boolean { return false diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts index 324dab504d61..399ac5e5bed8 100644 --- a/src/main/native-chat/structured-conversation-key.ts +++ b/src/main/native-chat/structured-conversation-key.ts @@ -48,7 +48,9 @@ export function terminalConversationKey(agent: string, id: string | null): Conve export function keysMayMatch(left: ConversationKey, right: ConversationKey): boolean { return ( left.family === right.family && - (left.id === null || right.id === null || sameConversationId(left.family, left.id, right.id)) && + (left.id === null || + right.id === null || + conversationKeyId(left) === conversationKeyId(right)) && (left.home === null || right.home === null || agentSessionAccountHomesEqual(left.home, right.home)) @@ -56,11 +58,9 @@ export function keysMayMatch(left: ConversationKey, right: ConversationKey): boo } // A session-file agent's terminal may name the file; Session History lists its file-name id. -function sameConversationId(family: string, left: string, right: string): boolean { - if (family === 'opencode') { - return left === right +export function conversationKeyId(key: ConversationKey): string | null { + if (key.id === null || key.family === 'opencode') { + return key.id } - const normalize = (id: string) => - (/[\\/]/.test(id) ? sessionIdFromFileName(id) : id).toLowerCase() - return normalize(left) === normalize(right) + return (/[/\\]/.test(key.id) ? sessionIdFromFileName(key.id) : key.id).toLowerCase() } diff --git a/src/main/native-chat/transcript-grok-turn-lifecycle.ts b/src/main/native-chat/transcript-grok-turn-lifecycle.ts new file mode 100644 index 000000000000..5f5b9eada09a --- /dev/null +++ b/src/main/native-chat/transcript-grok-turn-lifecycle.ts @@ -0,0 +1,56 @@ +import { createReadStream } from 'node:fs' +import { dirname, join } from 'node:path' +import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' +import { asRecord, parseJsonObject, timestampMs } from '../ai-vault/session-scanner-values' +import { isENOENT } from '../ipc/filesystem/filesystem-path-containment' +import { splitTranscriptStreamLines } from './transcript-stream-lines' + +/** Grok writes turn boundaries beside its transcript, in the session's events log. */ +export async function readGrokTurnLifecycle( + filePath: string, + sessionId: string +): Promise { + let latest: NativeChatTurnLifecycle | undefined + try { + for await (const { line } of splitTranscriptStreamLines( + createReadStream(join(dirname(filePath), 'events.jsonl')), + 16 * 1024 * 1024 + )) { + const event = asRecord(parseJsonObject(line)) + const parsed = timestampMs(event?.ts) + const timestamp = Number.isFinite(parsed) ? parsed : null + if (event?.type === 'turn_started') { + latest = + event.session_id === sessionId && typeof event.turn_number === 'number' + ? { + state: 'working', + turnId: `grok:${event.turn_number}:${String(event.ts)}`, + timestamp + } + : undefined + } else if (event?.type === 'turn_ended' && latest) { + const state = + event.outcome === 'completed' + ? 'completed' + : event.outcome === 'error' + ? 'failed' + : event.outcome === 'cancelled' || event.outcome === 'interrupted' + ? 'interrupted' + : null + if (state) { + latest = { + turnId: latest.turnId, + state: state === 'failed' ? 'completed' : state, + timestamp, + ...(state === 'failed' ? { outcome: 'failure' as const } : {}) + } + } + } + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + return latest +} diff --git a/src/main/native-chat/transcript-opencode-message-rows.ts b/src/main/native-chat/transcript-opencode-message-rows.ts new file mode 100644 index 000000000000..5c48bf5ef414 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-message-rows.ts @@ -0,0 +1,96 @@ +import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query' +import { zcodeTranscriptOrder } from '../ai-vault/session-scanner-zcode-order' +import { extractString, parseJsonObject } from '../ai-vault/session-scanner-values' +import type SyncDatabase from '../sqlite/sync-database' +import { rowsWithinBudget } from './transcript-opencode-row-budget' +import { + opencodeMessages, + OPENCODE_TRANSCRIPT_MAX_ROW_BYTES +} from './transcript-opencode-part-blocks' +export const PART_ID_BATCH = 100 + +export type MessageRow = { + message_rowid: number + transcript_order: number + id: string + time_created: number + time_updated: number + data: string | null +} + +export type PartRow = { + message_id: string + time_updated: number + data: string | null +} + +export function mapMessageRows( + db: SyncDatabase, + sessionId: string, + rows: MessageRow[], + rawBudget: { bytes: number }, + agent?: 'zcode' +): OpenCodeTranscriptItem[] { + if (rows.length === 0) { + return [] + } + const partsByMessage = new Map() + const ids = rows.map((row) => row.id) + for (let start = 0; start < ids.length; start += PART_ID_BATCH) { + const batch = ids.slice(start, start + PART_ID_BATCH) + const placeholders = batch.map(() => '?').join(', ') + const partRows = rowsWithinBudget( + db.prepare( + `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part p + WHERE session_id = ? AND message_id IN (${placeholders}) + ORDER BY ${agent === 'zcode' ? zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC') : 'rowid'} LIMIT 10001` + ), + rawBudget, + sessionId, + ...batch + ) + if (partRows.length > 10000) { + throw new Error('OpenCode transcript parts exceed their read limit') + } + for (const partRow of partRows) { + const list = partsByMessage.get(partRow.message_id) + if (list) { + list.push(partRow) + } else { + partsByMessage.set(partRow.message_id, [partRow]) + } + } + } + const items: OpenCodeTranscriptItem[] = [] + for (const row of rows) { + const partList = partsByMessage.get(row.id) ?? [] + const record = row.data === null ? null : parseJsonObject(row.data) + const role = extractString(record?.role) + const messages = opencodeMessages( + { + id: row.id, + role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', + timestamp: Number.isFinite(row.time_created) ? row.time_created : null + }, + row.data === null + ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] + : partList + ) + const updated = partList.reduce( + (max, row) => (row.time_updated > max ? row.time_updated : max), + 0 + ) + for (const message of messages.toReversed()) { + items.push({ + rowid: row.message_rowid, + fingerprint: `${row.time_updated}:${partList.length}:${updated}`, + message: { + ...message, + transcriptOffset: row.message_rowid, + ...(agent === 'zcode' ? { transcriptOrder: row.transcript_order } : {}) + } + }) + } + } + return items +} diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index e5509bd82b1e..cae548b68da8 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -1,3 +1,5 @@ +import { mapMessageRows } from './transcript-opencode-message-rows' +import type { MessageRow } from './transcript-opencode-message-rows' import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open' import { zcodeVisibleMessageFilter } from '../ai-vault/session-scanner-zcode-visibility' import { zcodeTranscriptOrder } from '../ai-vault/session-scanner-zcode-order' @@ -5,16 +7,20 @@ import { readOpenCode2TranscriptPage, readOpenCode2TranscriptSignal } from './transcript-opencode2-sqlite' -import type { NativeChatMessage } from '../../shared/native-chat-types' +import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' import type SyncDatabase from '../sqlite/sync-database' import { rowsOf, rowsWithinBudget } from './transcript-opencode-row-budget' -import { - opencodeMessages, - OPENCODE_TRANSCRIPT_MAX_ROW_BYTES -} from './transcript-opencode-part-blocks' +import { OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' +export { + PART_ID_BATCH, + type MessageRow, + type PartRow, + mapMessageRows +} from './transcript-opencode-message-rows' + // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. export type OpenCodeTranscriptItem = { @@ -27,6 +33,7 @@ export type OpenCodeTranscriptPage = { items: OpenCodeTranscriptItem[] hasMore: boolean beforeMessageRowId: number | null + lifecycle?: NativeChatTurnLifecycle } export type OpenCodeTranscriptSignal = { @@ -37,8 +44,6 @@ export type OpenCodeTranscriptSignal = { maxPartTimeUpdated: number } -const PART_ID_BATCH = 100 - function sessionExists(db: SyncDatabase, sessionId: string): boolean { return ( tableExists(db, 'session') && @@ -157,6 +162,7 @@ export function readOpenCodeTranscriptPage(args: { const rawBudget = { bytes: 0 } let cursor: number | undefined = args.beforeMessageRowId let hasMore = false + let lifecycle: NativeChatTurnLifecycle | undefined for (;;) { const rows = rowsWithinBudget( select, @@ -175,6 +181,40 @@ export function readOpenCodeTranscriptPage(args: { } hasMore = rows.length > limit const selected = hasMore ? rows.slice(0, limit) : rows + for (const row of selected) { + if (lifecycle || row.data === null) { + continue + } + const record = parseJsonObject(row.data) + const time = asRecord(record?.time) + if (record?.role === 'user') { + lifecycle = { state: 'working', turnId: row.id, timestamp: row.time_created } + } else if ( + record?.role === 'assistant' && + typeof record.parentID === 'string' && + typeof time?.completed === 'number' + ) { + const error = asRecord(record.error) + const state = + error?.name === 'MessageAbortedError' + ? 'interrupted' + : error || record.finish === 'error' + ? 'failed' + : typeof record.finish === 'string' && + record.finish !== 'tool-calls' && + record.finish !== 'unknown' + ? 'completed' + : null + if (state) { + lifecycle = { + state: state === 'failed' ? 'completed' : state, + ...(state === 'failed' ? { outcome: 'failure' as const } : {}), + turnId: record.parentID, + timestamp: time.completed + } + } + } + } cursor = selected.at(-1)!.message_rowid const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget, args.agent) pageBytes += Buffer.byteLength(JSON.stringify(mapped)) @@ -200,95 +240,10 @@ export function readOpenCodeTranscriptPage(args: { return { items, hasMore: hasMore || overshot, - beforeMessageRowId: overshot ? items[0]!.rowid : (cursor ?? null) + beforeMessageRowId: overshot ? items[0]!.rowid : (cursor ?? null), + ...(lifecycle ? { lifecycle } : {}) } } finally { db.close() } } - -type MessageRow = { - message_rowid: number - transcript_order: number - id: string - time_created: number - time_updated: number - data: string | null -} - -type PartRow = { - message_id: string - time_updated: number - data: string | null -} - -function mapMessageRows( - db: SyncDatabase, - sessionId: string, - rows: MessageRow[], - rawBudget: { bytes: number }, - agent?: 'zcode' -): OpenCodeTranscriptItem[] { - if (rows.length === 0) { - return [] - } - const partsByMessage = new Map() - const ids = rows.map((row) => row.id) - for (let start = 0; start < ids.length; start += PART_ID_BATCH) { - const batch = ids.slice(start, start + PART_ID_BATCH) - const placeholders = batch.map(() => '?').join(', ') - const partRows = rowsWithinBudget( - db.prepare( - `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part p - WHERE session_id = ? AND message_id IN (${placeholders}) - ORDER BY ${agent === 'zcode' ? zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC') : 'rowid'} LIMIT 10001` - ), - rawBudget, - sessionId, - ...batch - ) - if (partRows.length > 10000) { - throw new Error('OpenCode transcript parts exceed their read limit') - } - for (const partRow of partRows) { - const list = partsByMessage.get(partRow.message_id) - if (list) { - list.push(partRow) - } else { - partsByMessage.set(partRow.message_id, [partRow]) - } - } - } - const items: OpenCodeTranscriptItem[] = [] - for (const row of rows) { - const partList = partsByMessage.get(row.id) ?? [] - const record = row.data === null ? null : parseJsonObject(row.data) - const role = extractString(record?.role) - const messages = opencodeMessages( - { - id: row.id, - role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', - timestamp: Number.isFinite(row.time_created) ? row.time_created : null - }, - row.data === null - ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] - : partList - ) - const updated = partList.reduce( - (max, row) => (row.time_updated > max ? row.time_updated : max), - 0 - ) - for (const message of messages.toReversed()) { - items.push({ - rowid: row.message_rowid, - fingerprint: `${row.time_updated}:${partList.length}:${updated}`, - message: { - ...message, - transcriptOffset: row.message_rowid, - ...(agent === 'zcode' ? { transcriptOrder: row.transcript_order } : {}) - } - }) - } - } - return items -} diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index 5d5d16f77a2f..ae1a508f7c45 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -2,7 +2,7 @@ import { OPENCODE_CAPTURE_RECORD_LIMIT, OPENCODE_CAPTURE_TEXT_LIMIT } from '../ai-vault/opencode-transcript-capture-limits' -import type { NativeChatMessage } from '../../shared/native-chat-types' +import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { errorMessage } from '../ai-vault/session-scanner-values' import type { ReadTranscriptResult } from './transcript-reader' import { @@ -106,6 +106,7 @@ export async function readOpenCodeNativeChatTranscriptFull( let bytes = 0 let cursor: number | undefined let sourceEmpty = false + let lifecycle: NativeChatTurnLifecycle | undefined try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( sessionId, @@ -136,6 +137,7 @@ export async function readOpenCodeNativeChatTranscriptFull( if (pages.length === 0) { sourceEmpty = page.beforeMessageRowId === null && !page.hasMore } + lifecycle ??= page.lifecycle pages.push(page.items.map((item) => item.message)) records += page.items.length bytes += Buffer.byteLength(JSON.stringify(page.items)) @@ -150,5 +152,12 @@ export async function readOpenCodeNativeChatTranscriptFull( } catch (err) { return { error: errorMessage(err) } } - return { messages: pages.toReversed().flat(), sourceEmpty } + const messages = pages.toReversed().flat() + if (lifecycle && !lifecycle.turnId) { + lifecycle = { + ...lifecycle, + turnId: messages.findLast((message) => message.role === 'user')?.id ?? '' + } + } + return { messages, sourceEmpty, ...(lifecycle ? { lifecycle } : {}) } } diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 4a4c5a490d1e..df6b737f8458 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -7,7 +7,7 @@ import { opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' -import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { NativeChatBlock, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import type { OpenCodeTranscriptPage, OpenCodeTranscriptSignal, @@ -199,6 +199,7 @@ export function readOpenCode2TranscriptPage( let bytes = 0 let pageBytes = 0 let hasMore = false + let lifecycle: NativeChatTurnLifecycle | undefined while (items.length < limit) { const rows = statement.all(args.sessionId, cursor, Math.min(limit + 1, 8)) hasMore = rows.length === Math.min(limit + 1, 8) @@ -217,6 +218,33 @@ export function readOpenCode2TranscriptPage( if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') } + if (!lifecycle && typeof row.data === 'string') { + const record = parseJsonObject(row.data) + if (row.type === 'user' && typeof row.id === 'string') { + lifecycle = { + state: 'working', + turnId: `opencode:${row.id}`, + timestamp: typeof row.time_created === 'number' ? row.time_created : null + } + } else if (row.type === 'idle' && record) { + const state = + record.outcome === 'succeeded' + ? 'completed' + : record.outcome === 'interrupted' + ? 'interrupted' + : record.outcome === 'failed' + ? 'failed' + : null + if (state) { + lifecycle = { + state: state === 'failed' ? 'completed' : state, + ...(state === 'failed' ? { outcome: 'failure' as const } : {}), + turnId: '', + timestamp: typeof row.time_created === 'number' ? row.time_created : null + } + } + } + } const mapped = messageItems(value) cursor = row.cursor for (const item of mapped) { @@ -238,5 +266,10 @@ export function readOpenCode2TranscriptPage( break } } - return { items: items.toReversed(), hasMore, beforeMessageRowId: scannedRows ? cursor : null } + return { + items: items.toReversed(), + hasMore, + beforeMessageRowId: scannedRows ? cursor : null, + ...(lifecycle ? { lifecycle } : {}) + } } diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts index 7082b4efb2af..bb9e0925cc50 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.ts @@ -22,14 +22,15 @@ export type NativeChatTurnLifecycleDecoder = ( ) => NativeChatTurnLifecycle | null export function nativeChatTurnLifecycleDecoderForAgent( - agent: AgentType + agent: AgentType, + explicitOnly = false ): NativeChatTurnLifecycleDecoder | null { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) if (transcriptAgent === 'codex') { return decodeCodexTurnLifecycle } if (transcriptAgent === 'claude') { - return decodeClaudeTurnLifecycle + return (line, fallbackId) => decodeClaudeTurnLifecycle(line, fallbackId, explicitOnly) } return null } @@ -71,7 +72,8 @@ function isClaudeTerminalStopReason(value: unknown): boolean { export function decodeClaudeTurnLifecycle( line: string, - fallbackId: string + fallbackId: string, + explicitOnly = false ): NativeChatTurnLifecycle | null { const record = parseJsonObject(line) if (!record) { @@ -89,7 +91,8 @@ export function decodeClaudeTurnLifecycle( // Legacy rows may omit stop_reason; only complete prose without tool_use can supply that fallback. const isTerminal = isClaudeTerminalStopReason(stopReason) || - (stopReason == null && + (!explicitOnly && + stopReason == null && assistantHasRenderableContent(message) && !assistantHasToolUse(message)) if (isTerminal) { @@ -167,3 +170,39 @@ function assistantHasToolUse(message: Record | null): boolean { } return content.some((block) => asRecord(block)?.type === 'tool_use') } + +/** Pi persists explicit assistant stop reasons on the selected session branch. */ +export function decodePiTurnLifecycle( + line: string, + fallbackId: string +): NativeChatTurnLifecycle | null { + const record = parseJsonObject(line) + const message = asRecord(record?.message) + if (record?.type !== 'message' || !message) { + return null + } + const turnId = extractString(record.id) ?? fallbackId + const timestamp = lifecycleTimestamp(record.timestamp) + if (message.role === 'user') { + return { state: 'working', turnId, timestamp } + } + if (message.role !== 'assistant') { + return null + } + const state = + message.stopReason === 'aborted' + ? 'interrupted' + : message.stopReason === 'error' + ? 'failed' + : message.stopReason === 'stop' || message.stopReason === 'length' + ? 'completed' + : null + return state + ? { + state: state === 'failed' ? 'completed' : state, + ...(state === 'failed' ? { outcome: 'failure' as const } : {}), + turnId, + timestamp + } + : null +} diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index b3142e3009df..66e1108726b4 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -144,7 +144,7 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) { reader.close() } } - return { store, authority, readState } + return { store, authority, readState, directory, path } } // Runtime spawns bind only a pane admitted before the process starts. diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 37b54e3b02c5..d2c27e87522f 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -243,10 +243,11 @@ export class PtyBindingPersistenceOperations { admitTerminalPane( admission: TerminalPaneAdmission, - hostId?: string | null + hostId?: string | null, + check?: () => void ): Promise { return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( - admitPane(resolveHostId(hostId), admission, this.topology()) + admitPane(resolveHostId(hostId), admission, this.topology(), check) ) } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index e45b6350842e..52dd633fd83d 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -9,7 +9,7 @@ function unexpectedPreflight(): never { export function createPtySpawnCommitDependencies( runtime: OrcaRuntimeService, - store: Store + store?: Store ): PtySpawnIpcDeps & PtyRuntimeControllerDeps { return { runtime, diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 16610bd9ab64..7e5681ca069e 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' import type { CommandOf, LayoutApplyOutcome, @@ -16,6 +17,7 @@ import type { } from '../loading-store/store-runtime-state' import type { ExecutionHostId } from '../../../shared/execution-host' import { + closeLeafOrTab, applyLayout, applyEditorFileRenamesInPartitions, topologyCommitContext @@ -79,6 +81,9 @@ export class LayoutCommandPersistence { command: LayoutCommand, hostIds: readonly string[] = [], run: { + originalPresentation?: TerminalSurfaceCloseCapture + admit?: () => boolean + onApplied?: () => void live?: LayoutLiveContext stopBeforeWrite?: (ptyIds: readonly string[]) => boolean onEffects?: (effects: LayoutEffects) => void @@ -86,8 +91,68 @@ export class LayoutCommandPersistence { ): Promise { const context = this[layoutCommandPersistenceContext] const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] + if (run.originalPresentation && isTerminalClose(command)) { + const original = run.originalPresentation + const source = original.identity + const hostIds = () => [ + ...new Set([ + ...original.holders.map((holder) => holder.hostId), + ...context.sessions + .getWorkspaceSessionHostIds() + .filter((id) => + context.sessions + .getWorkspaceSession(id) + ?.tabsByWorktree[source.worktreeId]?.some((row) => row.id === source.tabId) + ) + ]) + ] + return context.runtime + .runDurableMutation(() => { + let closedTab = false + const mutation = closeLeafOrTab({ + worktreeId: command.workspace, + target: { kind: 'pane', tabId: source.tabId, leafId: source.leafId }, + options: { + allowMissing: true, + originalPresentation: original, + onClosed: (tab) => { + closedTab = tab + } + }, + requestedSession: context.sessions.getWorkspaceSession(resolved[0]), + ownerMatches: () => + (run.admit?.() ?? true) && + command.workspace === source.worktreeId && + ('tabId' in command + ? command.tabId === source.tabId + : command.type === 'closeTabs' && + command.tabIds.length === 1 && + command.tabIds[0] === source.tabId), + hostIds, + getSession: (id) => context.sessions.getWorkspaceSession(id), + setSession: (session, id) => { + const domain = assignWorkspaceSessionPartition(context.runtime.state, id, session) + context.runtime.dirtyProfileStateDomains?.add(domain) + }, + onClosed: () => {} + })() + if (mutation.value) { + return { ...mutation, value: mutation.value } + } + return { + ...mutation, + value: { ok: true, result: { closed: closedTab ? [source.tabId] : [] } } as const + } + }) + .then((outcome) => { + if (outcome instanceof Error) { + throw outcome + } + return outcome + }) + } let effects: LayoutEffects | undefined - const commit = raisingBindingGenerations( + const apply = raisingBindingGenerations( context.runtime, resolved, (hostId) => context.sessions.getWorkspaceSession(hostId), @@ -97,6 +162,16 @@ export class LayoutCommandPersistence { noteEffects: (noted) => (effects = noted) }) ) + const commit = () => { + if (run.admit && !run.admit()) { + return { value: { ok: false as const, code: 'tab_not_found' as const }, persist: false } + } + const mutation = apply() + if (!(mutation.value instanceof Error) && mutation.value.ok) { + run.onApplied?.() + } + return mutation + } // Applied closes must stay closed even when persistence fails. const applied: Promise = ORCA_LAYOUT_OWNER_IS_MAIN && context.writeLater && isTerminalClose(command) diff --git a/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts b/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts new file mode 100644 index 000000000000..4885e78e881b --- /dev/null +++ b/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts @@ -0,0 +1,84 @@ +import { expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { + fixture, + admitRuntimeSpawnPane +} from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { + captureTerminalSurfaceIdentity, + type TerminalSurfaceCloseCapture +} from '../../../shared/terminal-tab-close' +import { applyWindowLayoutCommand } from '../../runtime/runtime-layout-apply' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +const pane = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +} +const command = { + type: 'closePane' as const, + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId +} +async function opened() { + const opened = await fixture() + await admitRuntimeSpawnPane(opened.store, pane) + const identity = captureTerminalSurfaceIdentity( + opened.store.getWorkspaceSession(), + pane.worktreeId, + pane.tabId, + pane.leafId + ) + if (!identity) { + throw new Error('Missing original owner') + } + const originalPresentation: TerminalSurfaceCloseCapture = { + identity, + holders: [{ hostId: 'local', identity }] + } + return { ...opened, originalPresentation } +} + +it('requires the captured close write, retains the applied removal on failure and reloads its later durable intent', async () => { + const { store, authority, originalPresentation, readState, directory, path } = await opened() + authority.failNextWrite() + await expect( + applyWindowLayoutCommand(store, () => ['local'], command, undefined, { + originalPresentation, + admit: () => true + }) + ).rejects.toThrow('profile_state_write_failed') + expect(store.getWorkspaceSession().tabsByWorktree[pane.worktreeId].map((row) => row.id)).toEqual([ + 'tab-local' + ]) + expect( + readState().workspaceSession.tabsByWorktree[pane.worktreeId].map( + (row: { id: string }) => row.id + ) + ).toContain(pane.tabId) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + const reloaded = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(path, 'coordination-test') + }) + try { + expect( + reloaded.getWorkspaceSession().tabsByWorktree[pane.worktreeId].map((row) => row.id) + ).toEqual(['tab-local']) + expect( + reloaded.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[pane.tabId] + ).toBeDefined() + } finally { + await reloaded.freezeWritesAsync() + } +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 327b789ffc2f..04642c29b03f 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -84,7 +84,8 @@ export function moveLeaf( export function admitPane( hostId: ExecutionHostId, admission: TerminalPaneAdmission, - context: TerminalTopologyCommitContext + context: TerminalTopologyCommitContext, + check?: () => void ): () => DurableProfileStateMutation { return traced( 'admit_pane', @@ -92,6 +93,7 @@ export function admitPane( fencedIfLayoutOwner( () => partitionsFence([hostId], context.getSession, false), () => { + check?.() // A legacy reattach can land outside the partition that recorded the tab's close. if ( admission.type === 'createTerminalTab' && diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 0b95575eaa90..280436c74b55 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -132,6 +132,7 @@ export class PiRpcSession { this.connection = ( deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) )(launch, { + onPhysicalExit: input.onPhysicalExit, onRecord: (frame) => frame.type === 'extension_ui_request' ? this.dialogs.receive(frame) diff --git a/src/main/providers/agent-foreground-process-remote-evidence.ts b/src/main/providers/agent-foreground-process-remote-evidence.ts index 4a76d98f2478..2a378dde55ea 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.ts @@ -1,3 +1,4 @@ +import { resolveSuspendedAgentProcessFromPs } from './suspended-agent-process' import { hasControllingTty } from '../../shared/posix-controlling-terminal' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import type { @@ -104,10 +105,12 @@ export function resolveRemoteForegroundEvidenceFromRows( if (candidate && !candidate.row.startTime) { return { ...metadata, verdict: 'unverifiable', reason: 'candidate_start_time_missing' } } + const suspendedAgentProcess = resolveSuspendedAgentProcessFromPs(rows, root.pid) return { ...metadata, verdict: 'live', processName: candidate?.name.processName ?? null, + ...(suspendedAgentProcess ? { suspendedAgentProcess } : {}), fence } } diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index bc0eebca9a3b..630bb14e32a6 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,3 +1,4 @@ +import { resolveSuspendedAgentProcessFromPs } from './suspended-agent-process' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { unreadableArgvName, type ProcessTableRow } from '../../shared/process-table-snapshot' @@ -37,6 +38,7 @@ export { export type AgentForegroundProcessResolution = { available: boolean processName: string | null + suspendedAgentProcess?: string /** Windows liveness anchor for an unambiguous recognized name; omitted for fallbacks and POSIX. */ processId?: number /** Windows: the scan proved the caller's `anchorProcessId` is now a non-agent. */ @@ -172,9 +174,11 @@ export async function resolveAgentForegroundProcessWithAvailability( ) { return { available: false, processName: fallbackProcess } } + const suspendedAgentProcess = resolveSuspendedAgentProcessFromPs(rows, shellPid) return { available: true, - processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess, + ...(suspendedAgentProcess ? { suspendedAgentProcess } : {}) } } catch { // Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent. diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index d7f378e27df2..e77ee24ce50f 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -37,7 +37,8 @@ import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows- export async function inspectLocalPtyChildProcesses( id: string, fresh = true, - ignoreDetached = false + ignoreDetached = false, + strict = false ): Promise { const proc = ptyProcesses.get(id) if (!proc) { @@ -56,7 +57,7 @@ export async function inspectLocalPtyChildProcesses( ) return ptyProcesses.get(id) === proc ? verdict : 'unverifiable' } - if (ptyProcessNameIsSpawnFile(proc)) { + if (strict || ptyProcessNameIsSpawnFile(proc)) { const snapshot = await getStrictProcessTableSnapshotWithAge() return ptyProcesses.get(id) === proc ? inspectSpawnFileChildProcessesFromRows( @@ -243,7 +244,8 @@ async function readPosixSteadyState( export async function confirmLocalPtyForegroundProcess( id: string, - decision = false + decision = false, + observeSuspended?: (name: string | undefined) => void ): Promise { const proc = ptyProcesses.get(id) if (!proc) { @@ -268,6 +270,9 @@ export async function confirmLocalPtyForegroundProcess( if (ptyProcesses.get(id) !== proc) { return null } + if (resolution.available) { + observeSuspended?.(resolution.suspendedAgentProcess) + } if (windowsDecision) { const recognized = recognizeAgentProcessFromCommandLine(resolution.processName ?? '') if (resolution.available && recognized) { diff --git a/src/main/providers/local-pty-provider-state.ts b/src/main/providers/local-pty-provider-state.ts index 956934904e4d..4c2da4ec5b36 100644 --- a/src/main/providers/local-pty-provider-state.ts +++ b/src/main/providers/local-pty-provider-state.ts @@ -1,5 +1,5 @@ import type * as pty from 'node-pty' -import type { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' import type { IPtyProvider } from './pty-provider-contract' import type { PtyDataEvent } from './pty-provider-events' @@ -52,7 +52,43 @@ export const ptyDisposables = new Map void }[]>() export const ptyExitDisposables = new Map void }>() export const ptyCleanupCallbacks = new Map void>() export const ptyTerminationMode = new Map() -export const ptyPhysicalExits = new Map() +export class LocalPtyPhysicalExit extends PhysicalExitTracker { + useRevision = 0 + closing = false + pendingCommand = false + witness?: { token: string; revision: number; at: number } +} +export const ptyPhysicalExits = new Map() + +export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 + +export function createPtyPhysicalExit(id: string): void { + ptyPhysicalExits.set(id, new LocalPtyPhysicalExit()) +} + +export function waitForPtyPhysicalExit( + id: string, + physicalExit?: PhysicalExitTracker +): Promise { + if (!physicalExit) { + return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) + } + return physicalExit.waitForExit( + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, + () => new Error(`Timed out waiting for PTY process exit: ${id}`) + ) +} + +export function noteLocalPtyUse(id: string): boolean { + const owner = ptyPhysicalExits.get(id) + if (owner?.closing) { + return false + } + if (owner) { + owner.useRevision++ + } + return true +} // macOS TCC wrapper status is not the shell/agent exit status (STA-4536). export const ptyReportsChildExitStatus = new Map() export const ptyForceKillTimers = new Map>() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index c85a14336c31..5616fd69276f 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,3 +1,7 @@ +import type { + PtyFencedTerminationRequest, + PtyFencedTerminationOutcome +} from '../../shared/pty-fenced-termination' import type * as pty from 'node-pty' import type { IPtyProvider, @@ -25,6 +29,8 @@ import { setPtyOwnerHostColors } from '../../shared/pty-owner-color-query-colors' import { + ptyPhysicalExits, + noteLocalPtyUse, advanceLoadGeneration, clearPtyState, pendingLocalPtySpawns, @@ -79,7 +85,11 @@ export class LocalPtyProvider implements IPtyProvider { } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. - async attach(_id: string): Promise {} + async attach(id: string): Promise { + if (!noteLocalPtyUse(id)) { + throw new Error('terminal_handle_stale') + } + } hasPty(id: string): boolean { return ptyProcesses.has(id) } @@ -109,7 +119,10 @@ export class LocalPtyProvider implements IPtyProvider { return getLocalPtyAppliedSize(id) } - shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; expectedIncarnationId?: string } + ): Promise { return shutdownLocalPty(id, opts) } @@ -159,14 +172,27 @@ export class LocalPtyProvider implements IPtyProvider { childProcessEvidence: 'unverifiable' } } - const foregroundProcess = await (process.platform === 'win32' && options?.steadyState !== true - ? confirmLocalPtyForegroundProcess(id, true) + const owner = ptyPhysicalExits.get(id) + if (options?.captureSwitchWitness && owner) { + owner.witness = { + token: options.captureSwitchWitness, + revision: owner.useRevision, + at: performance.now() + } + } + let suspendedAgentProcess: string | undefined + const foregroundProcess = await (options?.scanChildProcesses || + (process.platform === 'win32' && options?.steadyState !== true) + ? confirmLocalPtyForegroundProcess(id, process.platform === 'win32', (name) => { + suspendedAgentProcess = name + }) : getLocalPtyForegroundProcess(id)) // A steady-state poll never decides adoption or close, so it may read the cached process table. const childProcessEvidence = await inspectLocalPtyChildProcesses( id, options?.steadyState !== true, - options?.ignoreDetached === true + options?.ignoreDetached === true, + Boolean(options?.captureSwitchWitness) ) // Neither asynchronous inspection may publish a replacement pane's identity. if (ptyProcesses.get(id) !== proc || ptyIncarnations.get(id) !== incarnation) { @@ -178,11 +204,61 @@ export class LocalPtyProvider implements IPtyProvider { } return { foregroundProcess, + ...(suspendedAgentProcess ? { suspendedAgentProcess } : {}), + ...(options?.captureSwitchWitness && owner?.witness + ? { switchWitness: owner.witness.token } + : {}), hasChildProcesses: childProcessEvidence !== 'no-children', childProcessEvidence } } + supportsFencedTermination(): boolean { + return true + } + async killFenced(request: PtyFencedTerminationRequest): Promise { + const proc = ptyProcesses.get(request.id) + if (!proc) { + return { outcome: 'already_exited' } + } + if (ptyIncarnations.get(request.id) !== request.expectedIncarnationId) { + return { outcome: 'refused', reason: 'replaced' } + } + const owner = ptyPhysicalExits.get(request.id) + const witness = owner?.witness + const condition = request.switchCondition + if (!condition || !witness || condition.token !== witness.token) { + return { outcome: 'refused', reason: 'unsupported' } + } + if (!condition.ownedSpawn) { + return { outcome: 'refused', reason: 'unsupported' } + } + const deadline = witness.at + condition.remainingMs + const current = () => + ptyProcesses.get(request.id) === proc && + ptyPhysicalExits.get(request.id) === owner && + owner?.useRevision === witness.revision && + !owner?.closing && + !pendingLocalPtySpawns.has(request.id) && + performance.now() < deadline + if (!current()) { + return { outcome: 'refused', reason: 'unverifiable' } + } + try { + await shutdownLocalPty(request.id, { + immediate: true, + expectedIncarnationId: request.expectedIncarnationId, + switchCheck: current, + switchOwnedSpawn: condition.ownedSpawn + }) + } catch { + return { outcome: 'refused', reason: 'unverifiable' } + } + return ptyProcesses.get(request.id) === proc + ? { outcome: 'refused', reason: 'unverifiable' } + : { outcome: 'ended' } + } + getForegroundProcess(id: string): Promise { return getLocalPtyForegroundProcess(id) } diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index 5e4e805cc844..6e55be2bc6ec 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -27,6 +27,7 @@ import { ptyInitialCwd, ptyLoadGeneration, ptyPhysicalExits, + noteLocalPtyUse, ptyProcesses, ptyReportsChildExitStatus, ptyShellPath, @@ -110,7 +111,11 @@ export function activateLocalPtySession(args: { shellPath: plan.shellPath, wslDistro: spawnedWslDistro }), - write: (data) => proc.write(data), + write: (data) => { + if (noteLocalPtyUse(id)) { + proc.write(data) + } + }, onEmission: emitIngressData }) startupIngressByPty.set(id, startupIngress) @@ -199,6 +204,10 @@ export function activateLocalPtySession(args: { reason: staging.failure }) } + const physical = ptyPhysicalExits.get(id) + if (physical) { + physical.pendingCommand = true + } writeStartupCommandWhenShellReady( readiness.shellReadyPromise, proc, @@ -207,7 +216,18 @@ export function activateLocalPtySession(args: { readiness.setStartupCommandCleanup(cleanup) }, { - bracketedPasteSafe + bracketedPasteSafe, + beforeWrite: () => { + if (physical) { + physical.pendingCommand = false + } + try { + spawn.switchExecution?.check() + } catch { + return false + } + return ptyProcesses.get(id) === proc && noteLocalPtyUse(id) + } } ) } @@ -215,10 +235,21 @@ export function activateLocalPtySession(args: { // Memory collection needs a proven positive OS PID; node-pty can briefly publish zero during spawn. const rawPid = proc.pid const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null + const physical = ptyPhysicalExits.get(id) + if (spawn.switchExecution && physical) { + physical.witness = { + token: spawn.switchExecution.invocationId, + revision: physical.useRevision, + at: performance.now() + } + } return { id, incarnationId, pid, + ...(spawn.switchExecution && physical?.witness + ? { switchCleanupWitness: physical.witness.token } + : {}), startupCwdSource: 'request', ...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {}), ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index b9d23d363306..4f75c2b2da36 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -8,6 +8,7 @@ import { isWslAvailableAsync } from '../wsl' import { resolveGitBashPath } from '../git-bash' import { resolveProcessCwd } from './process-cwd' import { + noteLocalPtyUse, dataListeners, exitListeners, ptyIncarnations, @@ -29,6 +30,9 @@ export function writeLocalPty(id: string, data: string): boolean { if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) { return true } + if (!noteLocalPtyUse(id)) { + return false + } const proc = ptyProcesses.get(id) if (!proc) { return false diff --git a/src/main/providers/local-pty-shell-ready-startup-command.ts b/src/main/providers/local-pty-shell-ready-startup-command.ts index dc9e3ff4ac0c..2a0b3e7a9698 100644 --- a/src/main/providers/local-pty-shell-ready-startup-command.ts +++ b/src/main/providers/local-pty-shell-ready-startup-command.ts @@ -15,7 +15,7 @@ export function writeStartupCommandWhenShellReady( startupCommand: string, onExit: (cleanup: () => void) => void, // Only shells with active bracketed paste accept its escape wrapper without echoing it. - options: { bracketedPasteSafe?: boolean } = {} + options: { bracketedPasteSafe?: boolean; beforeWrite?: () => boolean } = {} ): void { let sent = false let postReadyTimer: ReturnType | null = null @@ -42,6 +42,9 @@ export function writeStartupCommandWhenShellReady( clearTimeout(postReadyTimer) postReadyTimer = null } + if (options.beforeWrite?.() === false) { + return + } // Keep the interactive shell alive after agent exit; write once past readiness to avoid incremental-paste drops. proc.write( buildStartupCommandSubmission(startupCommand, { diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index c862a6dbba4a..e24a9faa416d 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -1,5 +1,6 @@ import type { PtySpawnResult } from './pty-provider-contract' import { + noteLocalPtyUse, pendingLocalPtySpawns, ptyIncarnations, ptyProcesses, @@ -28,6 +29,9 @@ export async function runCancelableLocalPtySpawn( id: string, operation: (throwIfCanceled: () => void, signal: AbortSignal) => Promise ): Promise { + if (!noteLocalPtyUse(id)) { + throw new Error('terminal_handle_stale') + } const cancellation = new AbortController() const pendingSpawn: PendingLocalPtySpawn = { cancellation } const pending = pendingLocalPtySpawns.get(id) ?? new Set() diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 1b45cab8e0f3..115341739788 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -36,6 +36,7 @@ export async function spawnLocalPty( if (pendingShutdown) { await pendingShutdown.promise } + args.switchExecution?.check() const existing = reattachLocalPty(reattachId, args.cols, args.rows) if (existing) { return existing @@ -99,6 +100,7 @@ export async function spawnLocalPty( return reserveLocalPtySpawn(id, async () => { const checkCanceled = (): void => { throwIfCanceled() + args.switchExecution?.check() if (args.signal?.aborted) { throw new Error('client_disconnected') } @@ -110,6 +112,7 @@ export async function spawnLocalPty( return concurrentWinner } const pendingSpawn = spawn({ + assertCreateAllowed: checkCanceled, shellPath: plan.shellPath, shellArgs: plan.shellArgs, cols: args.cols, @@ -130,12 +133,16 @@ export async function spawnLocalPty( try { checkCanceled() } catch (error) { - try { - spawnResult.process.kill('SIGKILL') - } finally { - destroyPtyProcess(spawnResult.process) + if (args.switchExecution) { + args = { ...args, command: undefined } + } else { + try { + spawnResult.process.kill('SIGKILL') + } finally { + destroyPtyProcess(spawnResult.process) + } + throw error } - throw error } args.onPtySpawnCommitted?.() plan.shellPath = spawnResult.shellPath diff --git a/src/main/providers/local-pty-termination.ts b/src/main/providers/local-pty-termination.ts index 355a987e05fa..517b57d210ef 100644 --- a/src/main/providers/local-pty-termination.ts +++ b/src/main/providers/local-pty-termination.ts @@ -1,5 +1,4 @@ import type * as pty from 'node-pty' -import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { killWithDescendantSweep } from '../pty-descendant-termination' import { terminateShutdownDescendants } from '../daemon/terminal-descendant-shutdown' import { @@ -16,11 +15,13 @@ import { ptyAgentSessionIds, ptyForceKillTimers, ptyPhysicalExits, + ptyIncarnations, ptyProcesses, ptyShutdownOperations, ptyTerminationMode, ptyLoadGeneration, runPtyCleanup, + waitForPtyPhysicalExit, type PtyShutdownOperation } from './local-pty-provider-state' import { @@ -28,23 +29,13 @@ import { cancelPendingLocalPtySpawns } from './local-pty-spawn-state' -export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000 export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 -export function createPtyPhysicalExit(id: string): void { - ptyPhysicalExits.set(id, new PhysicalExitTracker()) -} - -function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { - if (!physicalExit) { - return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) - } - return physicalExit.waitForExit( - LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, - () => new Error(`Timed out waiting for PTY process exit: ${id}`) - ) -} +export { + createPtyPhysicalExit, + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS +} from './local-pty-provider-state' const groupSweeps = new WeakMap() @@ -187,7 +178,9 @@ function requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolea async function shutdownTrackedPty( id: string, proc: pty.IPty, - operation: PtyShutdownOperation + operation: PtyShutdownOperation, + switchCheck?: () => boolean, + switchOwnedSpawn = false ): Promise { const physicalExit = ptyPhysicalExits.get(id) const signalRoot = (): void => { @@ -204,6 +197,24 @@ async function shutdownTrackedPty( // Typed agents also detach tool process groups; immediate close must snapshot before root exit. await killWithDescendantSweep(proc.pid, signalRoot, { ownsRoot: () => ptyProcesses.get(id) === proc, + ...(switchCheck + ? { + admitClose: async () => { + if (!switchCheck()) { + return false + } + if (!switchOwnedSpawn || !switchCheck()) { + return false + } + const owner = ptyPhysicalExits.get(id) + if (!owner || ptyProcesses.get(id) !== proc) { + return false + } + owner.closing = true + return true + } + } + : {}), terminateOwnedTree: () => terminatePtyJob(proc), terminateDescendants: terminateShutdownDescendants, // Group SIGKILL must wait out the SIGTERM grace so agents can run exit hooks (Claude SessionEnd). @@ -212,16 +223,36 @@ async function shutdownTrackedPty( } else { signalRoot() } - await waitForPtyPhysicalExit(id, physicalExit) + if (!switchCheck || operation.rootSignalled || ptyPhysicalExits.get(id)?.closing) { + await waitForPtyPhysicalExit(id, physicalExit) + } } export async function shutdownLocalPty( id: string, - opts: { immediate?: boolean; keepHistory?: boolean } + opts: { + immediate?: boolean + keepHistory?: boolean + expectedIncarnationId?: string + switchCheck?: () => boolean + switchOwnedSpawn?: boolean + } ): Promise { - cancelPendingLocalPtySpawns(id) + if (opts.expectedIncarnationId && ptyIncarnations.get(id) !== opts.expectedIncarnationId) { + throw new Error('terminal_handle_stale') + } + if (opts.switchCheck) { + if (!opts.switchCheck()) { + return + } + } else { + cancelPendingLocalPtySpawns(id) + } const pending = ptyShutdownOperations.get(id) if (pending) { + if (opts.switchCheck) { + return + } if (opts.immediate === true) { pending.immediate = true if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) { @@ -241,7 +272,7 @@ export async function shutdownLocalPty( rootSignalled: false, proc } - entry.promise = shutdownTrackedPty(id, proc, entry) + entry.promise = shutdownTrackedPty(id, proc, entry, opts.switchCheck, opts.switchOwnedSpawn) ptyShutdownOperations.set(id, entry) try { await entry.promise diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 83ddd0677cda..708359fe8910 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -119,6 +119,7 @@ export type ShellSpawnParams = { cwd: string env: Record ptySpawn: typeof pty.spawn + assertCreateAllowed?: () => void getShellReadyConfig?: ( shell: string ) => { args: string[] | null; env: Record } | null @@ -154,6 +155,7 @@ function spawnWindowsFallbackChain( // Skip the first entry: it is the primary that already failed above. for (const attempt of attempts.slice(1)) { try { + params.assertCreateAllowed?.() const proc = ptySpawn(attempt.shellPath, attempt.shellArgs, { name: termName, cols, @@ -171,6 +173,7 @@ function spawnWindowsFallbackChain( startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs } } catch { + params.assertCreateAllowed?.() // This fallback shell also failed -- try the next link in the chain. } } @@ -179,12 +182,13 @@ function spawnWindowsFallbackChain( export type PtyExecutableSpawnParams = Pick< ShellSpawnParams, - 'termName' | 'cols' | 'rows' | 'cwd' | 'env' | 'ptySpawn' + 'termName' | 'cols' | 'rows' | 'cwd' | 'env' | 'ptySpawn' | 'assertCreateAllowed' > & { file: string; args: string[] } /** One executable with an argv array, no shell and no fallback; throws when it can't start. */ export function spawnPtyExecutable(params: PtyExecutableSpawnParams): pty.IPty { const { file, args, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params + params.assertCreateAllowed?.() return ptySpawn(file, args, { name: termName, cols, @@ -226,7 +230,8 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu rows, cwd, env, - ptySpawn + ptySpawn, + assertCreateAllowed: params.assertCreateAllowed }), shellPath, reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) @@ -270,6 +275,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu fallbackReady?.args ?? ['-l'], env ) + params.assertCreateAllowed?.() const proc = ptySpawn(wrapped.file, wrapped.args, { name: termName, cols, @@ -286,6 +292,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) } } catch { + params.assertCreateAllowed?.() // Fallback also failed -- try next. } } diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 2810a5915578..7720c7fb3b42 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -17,6 +17,7 @@ type CompletionSensitivePtyProvider = IPtyProvider & { /** scanChildProcesses requests decision-time evidence; steady polls may rely on the next tick to correct stale reads. */ export type PtyProcessInspectionOptions = { + captureSwitchWitness?: string expectedIncarnationId?: PtyIncarnationId scanChildProcesses?: boolean /** Adoption only: ignore members outside both shell ancestry and console; close still reaches the whole job. */ diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 6b15dbbd9c71..eb620a72d804 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -20,6 +20,7 @@ import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { TerminalOwner } from '../../shared/terminal-owner' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { AgentSessionSwitchAuthority } from '../../shared/agent-session-switch-execution' export type { PtyBackgroundStreamEvent, @@ -111,6 +112,7 @@ export type PtySpawnOptions = { onPtySpawnCommitted?: () => void /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority } export type { PtyProcessInfo, PtySpawnResult } diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 118e6e5702e3..528fd2ff54b4 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -7,6 +7,11 @@ import type { TerminalOwner } from '../../shared/terminal-owner' import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = TerminalLaunchResult & { + /** A dispatched child remains owned after its initiating handoff lost authority. */ + switchOutcomeUnknown?: true + switchCleanupWitness?: string + /** Main-only placement reconciliation of the same dispatched process; never a provider identity. */ + switchRecoveredSurface?: { tabId: string; leafId: string } /** Execution-host probe; absent on older hosts and reattachments. */ openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult diff --git a/src/main/providers/suspended-agent-process.ts b/src/main/providers/suspended-agent-process.ts new file mode 100644 index 000000000000..2c93802d8fc4 --- /dev/null +++ b/src/main/providers/suspended-agent-process.ts @@ -0,0 +1,17 @@ +import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection' +import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' + +/** Observation only: stopped jobs remain owned work, but never authorize foreground input routing. */ +export function resolveSuspendedAgentProcessFromPs( + rows: readonly ProcessTableRow[], + shellPid: number +): string | null { + const descendants = collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid) + const stopped = descendants.filter((row) => /^[Tt]/.test(row.stat)) + const selected = selectForegroundProcessCandidate(stopped, descendants) + return selected + ? resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, descendants) + : null +} diff --git a/src/main/pty-descendant-ancestry.ts b/src/main/pty-descendant-ancestry.ts new file mode 100644 index 000000000000..89e358a25e9b --- /dev/null +++ b/src/main/pty-descendant-ancestry.ts @@ -0,0 +1,52 @@ +import type { DescendantSnapshot } from './pty-descendant-termination' +import type { ProcessTableRow } from './pty-process-table-parser' + +export function collectDescendantRows( + rootPid: number, + table: ProcessTableRow[], + capturedAtMs = Date.now() +): DescendantSnapshot { + const childrenByPpid = new Map() + let rootRow: ProcessTableRow | null = null + let duplicateRoot = false + for (const row of table) { + if (row.pid === rootPid) { + // Duplicate root rows from a non-atomic read leave its identity ambiguous. + duplicateRoot = rootRow !== null + rootRow ??= row + continue + } + const siblings = childrenByPpid.get(row.ppid) + if (siblings) { + siblings.push(row) + } else { + childrenByPpid.set(row.ppid, [row]) + } + } + // After root exit, descendants reparent; remaining rows with its old PID as parent may reflect PID reuse. + if (!rootRow || duplicateRoot) { + return { rootPgid: null, descendants: [], capturedAtMs } + } + const descendants: ProcessTableRow[] = [] + const queue = [rootPid] + const visited = new Set(queue) + for (let nextIndex = 0; nextIndex < queue.length; nextIndex += 1) { + const pid = queue[nextIndex] + for (const child of childrenByPpid.get(pid) ?? []) { + // Non-atomic ps output can contain duplicate or cyclic rows; never loop on them. + if (visited.has(child.pid)) { + continue + } + visited.add(child.pid) + descendants.push(child) + queue.push(child.pid) + } + } + return { + root: { pid: rootRow.pid, startedAt: rootRow.startedAt }, + rootPgid: rootRow.pgid, + descendants, + capturedAtMs, + reDerivedPids: new Set(descendants.map((row) => row.pid)) + } +} diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index f623a32ae931..02f1713367eb 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -4,6 +4,7 @@ import { verifyWindowsTreeKillTarget, type WindowsTreeKillTarget } from './windows-pty-root-identity' +import { collectDescendantRows } from './pty-descendant-ancestry' import type { ProcessTableRow } from './pty-process-table-parser' import { DESCENDANT_SNAPSHOT_TIMEOUT_MS, @@ -104,55 +105,7 @@ export function readProcessTableBeforeDeadline( }) } -export function collectDescendantRows( - rootPid: number, - table: ProcessTableRow[], - capturedAtMs = Date.now() -): DescendantSnapshot { - const childrenByPpid = new Map() - let rootRow: ProcessTableRow | null = null - let duplicateRoot = false - for (const row of table) { - if (row.pid === rootPid) { - // Duplicate root rows from a non-atomic read leave its identity ambiguous. - duplicateRoot = rootRow !== null - rootRow ??= row - continue - } - const siblings = childrenByPpid.get(row.ppid) - if (siblings) { - siblings.push(row) - } else { - childrenByPpid.set(row.ppid, [row]) - } - } - // After root exit, descendants reparent; remaining rows with its old PID as parent may reflect PID reuse. - if (!rootRow || duplicateRoot) { - return { rootPgid: null, descendants: [], capturedAtMs } - } - const descendants: ProcessTableRow[] = [] - const queue = [rootPid] - const visited = new Set(queue) - for (let nextIndex = 0; nextIndex < queue.length; nextIndex += 1) { - const pid = queue[nextIndex] - for (const child of childrenByPpid.get(pid) ?? []) { - // Non-atomic ps output can contain duplicate or cyclic rows; never loop on them. - if (visited.has(child.pid)) { - continue - } - visited.add(child.pid) - descendants.push(child) - queue.push(child.pid) - } - } - return { - root: { pid: rootRow.pid, startedAt: rootRow.startedAt }, - rootPgid: rootRow.pgid, - descendants, - capturedAtMs, - reDerivedPids: new Set(descendants.map((row) => row.pid)) - } -} +export { collectDescendantRows } from './pty-descendant-ancestry' type SnapshotDeps = { readTable?: ProcessTableReader @@ -182,6 +135,8 @@ export async function captureDescendantSnapshot( type KillSweepDeps = SnapshotDeps & TerminateDeps & { ownsRoot?: () => boolean + /** Conditional ordinary close admits only after the final census; refusal signals nothing. */ + admitClose?: () => Promise /** Shutdown can retain the owner until descendant escalation finishes. */ terminateDescendants?: (snapshot: DescendantSnapshot) => void | Promise awaitEscalation?: boolean | (() => boolean) @@ -198,7 +153,14 @@ export async function killWithDescendantSweep( deps: KillSweepDeps = {} ): Promise { const platform = deps.platform ?? process.platform + let admitted = !deps.admitClose if (platform === 'win32') { + if (deps.admitClose) { + admitted = await deps.admitClose() + if (!admitted) { + return + } + } try { if ((deps.ownsRoot?.() ?? true) && Number.isInteger(rootPid) && rootPid > 0) { // Owned jobs survive PID reuse and include descendants reparented away from the shell. @@ -218,12 +180,20 @@ export async function killWithDescendantSweep( } } } finally { - killRoot() + if (!deps.admitClose || (admitted && (deps.ownsRoot?.() ?? true))) { + killRoot() + } } return } const snapshot = await captureDescendantSnapshot(rootPid, deps) + if (deps.admitClose) { + admitted = await deps.admitClose() + if (!admitted) { + return + } + } let descendants: void | Promise = undefined const awaitEscalation = typeof deps.awaitEscalation === 'function' ? deps.awaitEscalation() : deps.awaitEscalation @@ -236,7 +206,9 @@ export async function killWithDescendantSweep( } await descendants } finally { - killRoot() + if (!deps.admitClose || (admitted && (deps.ownsRoot?.() ?? true))) { + killRoot() + } } return } @@ -248,7 +220,9 @@ export async function killWithDescendantSweep( : terminateDescendantSnapshot(snapshot, deps) } } finally { - killRoot() + if (!deps.admitClose || (admitted && (deps.ownsRoot?.() ?? true))) { + killRoot() + } } } diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts index 1bddf3c2e729..56b0879694aa 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts @@ -3,7 +3,7 @@ import { vi, type Mock } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' -import { OrcaRuntimeService } from '../orca-runtime' +import * as terminalClose from './terminal-close-runtime.test-fixture' import { withMainLayout } from './main-layout-close-fixture' import { CANARY_INCARNATION_ID, @@ -23,6 +23,7 @@ import { WORKTREE_PATH, canaryProcess, makeDeferred, + makeRepo, makeSession } from './orca-runtime-terminal-close-continuity-state-fixture' import { createCloseContinuityGraphFixture } from './orca-runtime-terminal-close-continuity-graph-fixture' @@ -49,7 +50,7 @@ export { } from './orca-runtime-terminal-close-continuity-state-fixture' export type CloseContinuityHarness = { - runtime: OrcaRuntimeService + runtime: terminalClose.TerminalCloseRuntime acknowledged: ReturnType closeTerminal: Mock<(...args: unknown[]) => unknown> closeTerminalPane: Mock<(...args: unknown[]) => unknown> @@ -91,21 +92,15 @@ function createHarness( let includeSiblingPty = false let victimPtyListed = true let flushError: Error | null = null - const repo = { - id: REPO_ID, - path: WORKTREE_PATH, - displayName: 'close-continuity', - badgeColor: '#000000', - addedAt: 1 - } + const repo = makeRepo() const store = withDurableRuntimeStore({ + ...terminalClose.terminalCloseStoreDefaults(), getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined, - getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), getProjects: () => [], - getWorkspaceSession: () => (sessionAvailable ? session : undefined), + get getWorkspaceSession() { + return sessionAvailable ? () => session : undefined + }, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, @@ -160,7 +155,7 @@ function createHarness( : []), ...(options.includeCanary ? [canaryProcess] : []) ]) - const runtime = new OrcaRuntimeService(store as never) + const runtime = new terminalClose.TerminalCloseRuntime(store) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these close paths call only the listed notifier methods. runtime.setNotifier({ closeTerminal, closeTerminalPane, closeTerminalTab } as never) runtime.setPtyController({ diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts index 21013296dafe..5909d55a0f23 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts @@ -71,6 +71,17 @@ export const canaryProcess = { title: 'Canary shell' } +export function makeRepo() { + return { + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'close-continuity', + upstream: null, + badgeColor: '#000000', + addedAt: 1 + } +} + export function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState { return { ...getDefaultWorkspaceSession(), diff --git a/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts b/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts new file mode 100644 index 000000000000..487958fb6810 --- /dev/null +++ b/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts @@ -0,0 +1,28 @@ +import { vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeStore } from '../runtime-store-contract' + +export class TerminalCloseRuntime extends OrcaRuntimeService { + dropArchivedPtyRecord(ptyId: string): void { + this.dropDisconnectedPtyRecord(ptyId) + } +} + +export function terminalCloseStoreDefaults() { + return { + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: () => ({ pr: {}, issue: {} }), + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } +} diff --git a/src/main/runtime/agent-session-founding-store.ts b/src/main/runtime/agent-session-founding-store.ts index 90966dc60ee5..fcd48aaca388 100644 --- a/src/main/runtime/agent-session-founding-store.ts +++ b/src/main/runtime/agent-session-founding-store.ts @@ -17,9 +17,10 @@ export function createAgentSessionFoundingStore( request, transactions.legacySessionTabIds ), - commit: async (request: AgentSessionReserveRequest) => { + commit: async (request: AgentSessionReserveRequest, check?: () => void) => { let heldBefore = true const result = await transactions.transact((draft) => { + check?.() heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 return commitAgentSessionFounding(draft, request, transactions.legacySessionTabIds) }) diff --git a/src/main/runtime/agent-session-record-publication.ts b/src/main/runtime/agent-session-record-publication.ts index bb532bfbde4f..cc984dbcc2e0 100644 --- a/src/main/runtime/agent-session-record-publication.ts +++ b/src/main/runtime/agent-session-record-publication.ts @@ -1,6 +1,5 @@ import type { AgentSessionStoreState } from './agent-session-store-state' import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' - export abstract class AgentSessionRecordPublication { constructor(protected readonly transactions: AgentSessionStoreTransactions) {} abstract holdsRecords(): boolean @@ -49,4 +48,8 @@ export abstract class AgentSessionRecordPublication { } return result } + + protected get state(): AgentSessionStoreState { + return this.transactions.state + } } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index b32208de2b87..f0660fb7b723 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -62,8 +62,7 @@ import { selectWritableAgentSessionRecord, listAgentSessionConversations, requireWritableAgentSessionRecord, - heldAgentSessionIds, - type AgentSessionStoreState + heldAgentSessionIds } from './agent-session-store-state' import { AgentSessionRecordTabs } from './agent-session/agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' @@ -113,10 +112,6 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs { return new AgentSessionRecordStore(transactions, args.hostId) } - private get state(): AgentSessionStoreState { - return this.transactions.state - } - /** Newer-build databases permit reads, never writes. */ get readOnly(): boolean { return this.transactions.readOnly @@ -200,22 +195,26 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs { commitProcessIdentity = (args: AgentSessionProcessIdentityCommit): Promise => this.mutate(args.sessionId, (record) => commitAgentSessionProcessIdentity({ ...args, record })) - async proveOwner(args: { - sessionId: string - fence: number - link?: AgentSessionProviderHandleLink - now: number - leaseTtlMs?: number - }): Promise { - return this.mutate(args.sessionId, (record) => - proveAgentSessionOwner({ + async proveOwner( + args: { + sessionId: string + fence: number + link?: AgentSessionProviderHandleLink + now: number + leaseTtlMs?: number + }, + check?: () => void + ): Promise { + return this.mutate(args.sessionId, (record) => { + check?.() + return proveAgentSessionOwner({ record, fence: args.fence, ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) - ) + }) } settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => diff --git a/src/main/runtime/agent-session-tab-layout-queue.ts b/src/main/runtime/agent-session-tab-layout-queue.ts index 6a8191c3cbf4..9414d8d12524 100644 --- a/src/main/runtime/agent-session-tab-layout-queue.ts +++ b/src/main/runtime/agent-session-tab-layout-queue.ts @@ -1,3 +1,4 @@ +import type { LayoutCommandRunOptions } from './runtime-layout-apply' import type { ExecutionHostId } from '../../shared/execution-host' import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -19,8 +20,11 @@ export function createChatTabLayoutQueue(deps: { sessionOf: (worktreeId: string) => WorkspaceSessionState | null /** Called only with a saved session; unplaceable worktrees throw. */ hostIdOf: (worktreeId: string) => ExecutionHostId - apply: (command: ChatTabLayoutCommand) => Promise -}): (changesNow: () => readonly QueuedChatTabChange[]) => void { + apply: ( + command: ChatTabLayoutCommand, + options?: LayoutCommandRunOptions + ) => Promise +}): (changesNow: () => readonly QueuedChatTabChange[], admit?: () => boolean) => Promise { let queue: Promise = Promise.resolve() const commandNow = ({ worktreeId, change }: QueuedChatTabChange) => { const session = deps.sessionOf(worktreeId) @@ -36,14 +40,15 @@ export function createChatTabLayoutQueue(deps: { change ) } - return (changesNow) => { - queue = queue.then(async () => { + return (changesNow, admit) => { + const result = queue.then(async () => { + let applied = true let changes: readonly QueuedChatTabChange[] try { changes = changesNow() } catch (error) { console.warn('[agent-session-tabs] reading chat tab changes failed:', error) - return + return false } for (const queued of changes) { try { @@ -51,14 +56,22 @@ export function createChatTabLayoutQueue(deps: { continue } const command = commandNow(queued) - const reply = command ? await deps.apply(command) : null + if (!command && admit && 'close' in queued.change) { + applied = false + } + const reply = command ? await deps.apply(command, admit ? { admit } : undefined) : null if (command && reply && !reply.ok) { + applied = false console.warn(`[agent-session-tabs] ${command.type} refused:`, reply.code) } } catch (error) { + applied = false console.warn('[agent-session-tabs] a chat tab change failed:', error) } } + return applied }) + queue = result.then(() => {}) + return result } } diff --git a/src/main/runtime/agent-session/agent-session-tab-layout.ts b/src/main/runtime/agent-session/agent-session-tab-layout.ts index bfce5765c218..7aa5fef84969 100644 --- a/src/main/runtime/agent-session/agent-session-tab-layout.ts +++ b/src/main/runtime/agent-session/agent-session-tab-layout.ts @@ -14,7 +14,7 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' /** Placement in the creating client's host-owned group/tab ids. */ -export type ChatTabPlacement = { groupId?: string; afterTabId?: string } +export type ChatTabPlacement = { groupId?: string; afterTabId?: string; index?: number } export type ChatTabChange = | { open: { sessionId: string; agent: AgentType; tabId: string; placement?: ChatTabPlacement } } @@ -34,11 +34,12 @@ export function chatTabLayoutCommand( const placement = 'open' in change ? change.open.placement : undefined const holds = (id: string | undefined) => workspace?.groups.some((group) => group.id === id) // Prefer an existing client group, then the active group; otherwise open creates one. + const afterGroupId = workspace?.groups.find((group) => + group.tabOrder.includes(placement?.afterTabId ?? '') + )?.id const groupId = holds(placement?.groupId) ? placement?.groupId - : holds(activeGroupId) - ? activeGroupId - : undefined + : (afterGroupId ?? (holds(activeGroupId) ? activeGroupId : undefined)) const after = placement?.afterTabId const afterTabId = after && @@ -63,7 +64,11 @@ export function chatTabLayoutCommand( agent, tabId: openedTabId, ...(groupId ? { groupId } : {}), - ...(afterTabId ? { afterTabId } : {}) + ...(afterTabId + ? { afterTabId } + : placement?.index !== undefined + ? { index: placement.index } + : {}) } } @@ -111,3 +116,41 @@ export function chatTabId(sessionId: string): string { structuredAgentSessionTabId(sessionId) ) } + +/** Select in the existing view without rebuilding membership from a partial runtime snapshot. */ +export function withSelectedChatTab( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + worktreeId: string, + sessionId: string +): WorkspaceSessionState | null { + const { loaded } = loadChatTabLayout(hostId, session, worktreeId) + const workspace = loaded.layout.workspaces[worktreeId] + const tab = workspace?.tabs.find( + (row) => row.kind === 'agent-session' && row.entityId === sessionId + ) + const group = tab && workspace?.groups.find((row) => row.tabOrder.includes(tab.id)) + if (!tab || !group) { + return null + } + const view = loaded.desktopView + return saveWorkspaceLayout({ + ...loaded, + desktopView: { + ...view, + activeTabTypeByWorktree: { ...view.activeTabTypeByWorktree, [worktreeId]: 'agent-session' }, + activeFileIdByWorktree: { ...view.activeFileIdByWorktree, [worktreeId]: tab.id }, + activeGroupIdByWorktree: { ...view.activeGroupIdByWorktree, [worktreeId]: group.id }, + groups: { + ...view.groups, + [worktreeId]: { + ...view.groups[worktreeId], + [group.id]: { + ...view.groups[worktreeId]?.[group.id], + activeTabId: tab.id + } + } + } + } + }) +} diff --git a/src/main/runtime/bind-first-pane.test-fixture.ts b/src/main/runtime/bind-first-pane.test-fixture.ts index 3ae7ed5e415a..c636c5c34870 100644 --- a/src/main/runtime/bind-first-pane.test-fixture.ts +++ b/src/main/runtime/bind-first-pane.test-fixture.ts @@ -1,8 +1,9 @@ // L80: emulate the PTY host's binding commit before registration in layout-owner builds; switch-off writes nothing. +import type { ExecutionHostId } from '../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getConnectionExecutionHostId } from '../../shared/execution-host' +import { captureFakeSpawnBinding } from './runtime-fake-spawn-binding.test-fixture' import { getPtyExecutionHost } from '../../shared/terminal-execution-host' import { asProfileSessionStore, storeOf } from './profile-session-store.test-fixture' import { reusedTerminalIdUnbinding } from '../persistence/loading-store/reused-terminal-id-unbind' @@ -87,7 +88,7 @@ export function withBoundPane( export function bindPaneFirst( runtime: OrcaRuntimeService, pane: BoundPane, - hostId = 'local' + hostId: ExecutionHostId = 'local' ): void { if (!ORCA_LAYOUT_OWNER_IS_MAIN) { return @@ -105,7 +106,7 @@ export function movePaneFirst( runtime: OrcaRuntimeService, fromTabId: string, pane: BoundPane, - hostId = 'local' + hostId: ExecutionHostId = 'local' ): void { if (!ORCA_LAYOUT_OWNER_IS_MAIN) { return @@ -169,61 +170,16 @@ export function committingSpawn< >( runtime: OrcaRuntimeService, spawn: (args: Args) => Promise, - hostId = 'local' + hostId: ExecutionHostId = 'local' ): (args: Args) => Promise { return async (args) => { + const commit = captureFakeSpawnBinding(runtime, args, hostId) const result = await spawn(args) - if (args.worktreeId && args.tabId && args.leafId && typeof result?.id === 'string') { - bindPaneFirst( - runtime, - { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - ptyId: result.id - }, - hostId - ) - } + commit(result) return result } } -/** Bind the pre-spawn pane, refusing one closed since; emulate it for stores without pane writes. */ -function commitIntoWrittenPane( - runtime: OrcaRuntimeService, - args: SpawnArgs, - result: unknown -): void { - const ptyId = result && typeof result === 'object' && 'id' in result ? result.id : null - const store = storeOf(runtime) - if ( - !store?.getWorkspaceSession || - !args.worktreeId || - !args.tabId || - !args.leafId || - typeof ptyId !== 'string' - ) { - return - } - const { worktreeId, tabId, leafId } = args - const holdsPane = (hostId: string): boolean => { - const session = store.getWorkspaceSession?.(hostId) - return ( - (session?.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) ?? false) && - layoutContainsLeafId(session?.terminalLayoutsByTabId?.[tabId]?.root ?? null, leafId) - ) - } - const hostId = !Reflect.has(store, 'admitTerminalPane') - ? 'local' - : [getConnectionExecutionHostId(args.connectionId), 'local'].find(holdsPane) - if (hostId) { - bindPaneFirst(runtime, { worktreeId, tabId, leafId, ptyId }, hostId) - } -} - -type SpawnArgs = Parameters>[0] - type PtyControllerRuntimePrototype = { setPtyController: (this: OrcaRuntimeService, controller: RuntimePtyController | null) => void } @@ -244,8 +200,6 @@ export function commitFakeSpawnsInRuntimeTests(prototype: PtyControllerRuntimePr setPtyController.call(this, controller) return } - const commit = (args: SpawnArgs, result: unknown): void => - commitIntoWrittenPane(this, args, result) // Wrap via accessor so later spy/reassignment spawns also commit. type Spawn = NonNullable const committed = new WeakMap() @@ -258,8 +212,9 @@ export function commitFakeSpawnsInRuntimeTests(prototype: PtyControllerRuntimePr let wrapped = committed.get(own) if (!wrapped) { wrapped = async (args) => { + const commit = captureFakeSpawnBinding(this, args) const result = await own.call(controller, args) - commit(args, result) + commit(result) return result } committed.set(own, wrapped) diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index d6ecdc9975bd..a8f9930f62a8 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -22,6 +22,39 @@ export class ClientSessionTabSelectionStore { private statesByClient = new Map>() private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null + private hostNavigationRevision = 0 + private hostSelectionRequest = { issued: 0, accepted: 0 } + + /** Only explicit host-target navigation supersedes the host's delayed selection. */ + noteHostNavigation(): void { + this.hostNavigationRevision++ + } + + captureHostSelection(deferAcceptance = false) { + const revision = this.hostNavigationRevision + const request = ++this.hostSelectionRequest.issued + let pending = true + const accept = () => { + this.hostSelectionRequest.accepted = Math.max(this.hostSelectionRequest.accepted, request) + } + if (!deferAcceptance) { + accept() + } + return Object.assign( + (consume = false) => { + const current = + pending && + revision === this.hostNavigationRevision && + request === this.hostSelectionRequest.accepted + if (current && consume) { + pending = false + } + return current + }, + { accept } + ) + } + // Persist across host restart so phones do not snap back to the first tab. hydrate(persisted: PersistedMobileClientTabSelections): void { for (const [clientNavigationId, selectionsByWorktree] of Object.entries( diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index 1c58a7260547..798eb1d1698a 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -1,12 +1,10 @@ import { randomUUID } from 'node:crypto' +import { preserveSessionTabOpenPlacements } from '../session-tabs/session-tab-open-placement' import { preserveTerminalRetirementProofs } from '../mobile-session-terminal-retirement-proof' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { startedMainTerminalSurfaces } from '../terminal/surface/main-terminal-surfaces' import { mainBindsUnendedTerminal } from '../terminal/surface/main-pane-verdict' -import { - headlessMobileSnapshotContentUnchanged, - mobileSnapshotIdentityUnchanged -} from '../mobile-session-snapshot-equality' +import { unchangedMainSessionSnapshotRebuild } from '../mobile-session-snapshot-equality' import type { TuiAgent } from '../../../shared/tui-agent' import { isTuiAgent } from '../../../shared/tui-agent-config' import type { RuntimeStore } from '../runtime-store-contract' @@ -63,7 +61,6 @@ import { import { RuntimeTerminalWait as RuntimeTerminalWaitController } from '../terminal/wait/terminal-wait' import type { PtyLivenessVerdict } from '../../../shared/pty-liveness-verdict' import type { HostScopedWorktreeKey } from '../../../shared/worktree/host-qualified-identity' - import { RuntimeConstructionServices } from './runtime-construction-services' export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServices { @@ -119,15 +116,14 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + snapshot = preserveSessionTabOpenPlacements(snapshot, existing) + if (snapshot.switchProjections === undefined && existing?.switchProjections) { + snapshot = { ...snapshot, switchProjections: existing.switchProjections } + } if (ORCA_LAYOUT_OWNER_IS_MAIN) { const merged = startedMainTerminalSurfaces(this)?.withMainOrder(snapshot) ?? snapshot // Main-order-only rebuilds are no-ops unless occupant/publication/retirement evidence changed. - if ( - existing && - mobileSnapshotIdentityUnchanged(existing, snapshot) && - headlessMobileSnapshotContentUnchanged(existing, merged) && - !headlessMobileSnapshotContentUnchanged(snapshot, merged) - ) { + if (unchangedMainSessionSnapshotRebuild(existing, snapshot, merged)) { return existing } snapshot = merged diff --git a/src/main/runtime/main-owned-workspace-runtime-fixture.ts b/src/main/runtime/main-owned-workspace-runtime-fixture.ts index e592e784573a..438e4925ea3e 100644 --- a/src/main/runtime/main-owned-workspace-runtime-fixture.ts +++ b/src/main/runtime/main-owned-workspace-runtime-fixture.ts @@ -1,13 +1,18 @@ -// Windowless main-owned layout fixture: apply in memory and notify writes so terminal listings refresh. import { getDefaultWorkspaceSession } from '../../shared/constants' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-session-contracts' +import { applyPtyBinding } from '../persistence/loading-store/pty-binding-session-update' +import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission' import { resolveHostId } from '../persistence/loading-store/session-host-partitions' import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { partialRuntimeStore } from './runtime-store-partial-fixture' + +// Windowless main-owned layout fixture: apply in memory and notify writes so terminal listings refresh. export const REPO = { id: 'repo-1', @@ -60,7 +65,13 @@ function session(split: boolean, pinned: boolean): WorkspaceSessionState { } } -export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolean } = {}) { +export function setupMainOwnedWorkspace( + opts: { + split?: boolean + pinned?: boolean + runtimeDeps?: ConstructorParameters[2] + } = {} +) { let current = session(opts.split === true, opts.pinned === true) const written = new Set<(hostId: string | null) => void>() const write = (next: WorkspaceSessionState): void => { @@ -71,6 +82,7 @@ export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolea } const store = withDurableRuntimeStore( withRuntimePaneAdmission({ + ...partialRuntimeStore(), getRepos: () => [REPO], getRepo: (id: string) => (id === REPO.id ? REPO : undefined), getWorkspaceSession: () => current, @@ -80,11 +92,21 @@ export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolea return () => written.delete(listener) }, setWorkspaceSession: (next: WorkspaceSessionState) => write(next), - applyLayoutCommand: async (command: LayoutCommand, hostIds: readonly string[] = []) => { + applyLayoutCommand: async ( + command: LayoutCommand, + hostIds: readonly string[] = [], + run?: { admit?: () => boolean; onApplied?: () => void } + ) => { + if (run?.admit && !run.admit()) { + return { ok: false as const, code: 'tab_not_found' as const } + } const change = applyCommandToSession(resolveHostId(hostIds[0]), current, command) if (change.session) { write(change.session) } + if (change.value.ok) { + run?.onApplied?.() + } return change.value }, getSettings: () => ({}), @@ -93,17 +115,8 @@ export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolea }) ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake carries every store method a close, rename and sleep reach. - const runtime = new OrcaRuntimeService(store as never) + const runtime = new OrcaRuntimeService(store as never, undefined, opts.runtimeDeps) const stopped: string[] = [] - runtime.setPtyController({ - write: () => true, - kill: () => true, - stopAndWait: async (ptyId: string) => { - stopped.push(ptyId) - return true - }, - getForegroundProcess: async () => null - }) runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) const ptyIds = opts.split ? [PTY_A, PTY_B] : [PTY_A] for (const [index, ptyId] of ptyIds.entries()) { @@ -113,10 +126,30 @@ export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolea // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both members are declared on OrcaRuntimeService's inheritance chain. const internals = runtime as unknown as { issuePtyHandle: (pty: unknown) => string - ptysById: Map + ptysById: Map } const handleOf = (ptyId: string): string => internals.issuePtyHandle(internals.ptysById.get(ptyId)) + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId: string) => { + stopped.push(ptyId) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => + [...internals.ptysById.values()] + .filter((pty) => pty.connected) + .map((pty) => ({ + id: pty.ptyId, + cwd: REPO.path, + title: pty.title ?? '', + worktreeId: pty.worktreeId, + terminalHandle: handleOf(pty.ptyId), + ...(pty.incarnationId ? { incarnationId: pty.incarnationId } : {}) + })) + }) let frames = 0 runtime.subscribeWorkspaceLayouts(() => { frames += 1 @@ -124,6 +157,10 @@ export function setupMainOwnedWorkspace(opts: { split?: boolean; pinned?: boolea return { runtime, stopped, + bindPty: (binding: PersistPtyBindingArgs) => { + applyPtyBinding(binding, current, WORKTREE_ID, `${binding.tabId}:${binding.leafId}`) + write(current) + }, frames: () => frames, handleOf, ptyRecord: (ptyId: string) => internals.ptysById.get(ptyId), diff --git a/src/main/runtime/mobile-session-layout-projection.ts b/src/main/runtime/mobile-session-layout-projection.ts index d10cb89f79a0..a7c1768ba233 100644 --- a/src/main/runtime/mobile-session-layout-projection.ts +++ b/src/main/runtime/mobile-session-layout-projection.ts @@ -1,3 +1,7 @@ +import { + placeUnpinnedSessionTabAtCapture, + type CapturedSessionTabPlacement +} from '../../shared/session-tab-placement' import { createHash, randomUUID } from 'node:crypto' import { isTerminalLeafId } from '../../shared/stable-pane-id' import type { @@ -285,3 +289,24 @@ export function buildMaterializedHeadlessParentLayout( } } } + +/** Captured neighbors settle into the current group through ordinary pin-aware insertion. */ +export function placeHeadlessSessionTabAtCapture( + groups: RuntimeMobileSessionTabGroup[], + tabs: readonly RuntimeMobileSessionSnapshotTab[], + tabId: string, + captured: CapturedSessionTabPlacement +): void { + const group = groups.find((candidate) => candidate.tabOrder.includes(tabId)) + if (group) { + group.tabOrder = placeUnpinnedSessionTabAtCapture( + group.tabOrder, + tabs.map((tab) => ({ + id: tab.type === 'terminal' ? tab.parentTabId : tab.id, + isPinned: tab.isPinned === true + })), + tabId, + captured + ) + } +} diff --git a/src/main/runtime/mobile-session-snapshot-equality.ts b/src/main/runtime/mobile-session-snapshot-equality.ts index d3f1a067be51..0298a725ab04 100644 --- a/src/main/runtime/mobile-session-snapshot-equality.ts +++ b/src/main/runtime/mobile-session-snapshot-equality.ts @@ -74,3 +74,17 @@ export function mobileSnapshotIdentityUnchanged( mobileSnapshotValueEqual(next.retiredTerminalSurfaces, existing.retiredTerminalSurfaces)) ) } + +/** Main-order reconciliation can reuse an unchanged occupant without publishing a new version. */ +export function unchangedMainSessionSnapshotRebuild( + existing: RuntimeMobileSessionTabsSnapshot | undefined, + snapshot: RuntimeMobileSessionTabsSnapshot, + merged: RuntimeMobileSessionTabsSnapshot +): existing is RuntimeMobileSessionTabsSnapshot { + return Boolean( + existing && + mobileSnapshotIdentityUnchanged(existing, snapshot) && + headlessMobileSnapshotContentUnchanged(existing, merged) && + !headlessMobileSnapshotContentUnchanged(snapshot, merged) + ) +} diff --git a/src/main/runtime/mobile-session-tab-close-outcome.ts b/src/main/runtime/mobile-session-tab-close-outcome.ts index 96e51da8b0bf..82c9db50882f 100644 --- a/src/main/runtime/mobile-session-tab-close-outcome.ts +++ b/src/main/runtime/mobile-session-tab-close-outcome.ts @@ -1,4 +1,10 @@ -import type { RuntimeMobileSessionTabCloseResult } from '../../shared/runtime-types' +import type { TerminalSurfaceCloseCapture } from '../../shared/terminal-tab-close' +import type { + RuntimeMobileSessionTabCloseResult, + RuntimeMobileSessionRetiredTerminalSurface, + RuntimeSessionTabCloseReason +} from '../../shared/runtime-types' +import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' import type { ClientSessionTabSelectionStore } from './client-session-tab-selection' // Tombstones hide tabs until activation; only proven host retirement may hide a still-published tab. @@ -43,3 +49,16 @@ export function refusedMobileSessionTabClose( ...(options.snapshotRepublished ? { snapshotRepublished: true as const } : {}) } as MobileSessionTabCloseOutcome } + +export type MobileSessionTabCloseOptions = { + originalPresentation?: TerminalSurfaceCloseCapture + rendererOwnsPresentation?: boolean + retirementProofs?: RuntimeMobileSessionRetiredTerminalSurface[] + reason?: RuntimeSessionTabCloseReason + expectedPublicationEpoch?: string + expectedTerminalHandle?: string + clientNavigationId?: string + localPtyTeardownOwnedExternally?: boolean + expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority + force?: boolean +} diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 93a4a48ee20c..93bddc22f1b3 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -53,15 +53,18 @@ export const spawnShellInputs = ({ /** Abort disconnected callers, refuse sleeping workspaces, and pass fork intent and the user's act for host validation. */ export function callerSpawnScope( - opts: Pick -): { - signal?: AbortSignal + opts: Pick< + TerminalCreateOptions, + 'signal' | 'refuseSleptWorktree' | 'spawnIntent' | 'switchExecution' | 'wslUserAct' + > +): Pick & { refuseSleptWorktree?: true spawnIntent?: SpawnIntent wslUserAct?: true } { return { ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.switchExecution ? { switchExecution: opts.switchExecution } : {}), ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}), ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), ...(opts.wslUserAct ? { wslUserAct: true } : {}) diff --git a/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts b/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts new file mode 100644 index 000000000000..da071243f3f6 --- /dev/null +++ b/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts @@ -0,0 +1,107 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from '../persistence/loading-store/profile-state-delayed-authority-fixture' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { ClientSessionTabSelectionStore } from './client-session-tab-selection' +import { TerminalCloseRuntime } from './__fixtures__/terminal-close-runtime.test-fixture' +import { createCloseContinuityGraphFixture } from './__fixtures__/orca-runtime-terminal-close-continuity-graph-fixture' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' + +it('retires the authenticated final pane through the real Store and forgets its tab selection', async () => { + const { store, readState } = await fixture() + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Original retirement', + badgeColor: '#000000', + addedAt: 1 + }) + store.setWorkspaceSession(makeSession()) + await store.flushPendingOrThrowAsync() + const runtime = new TerminalCloseRuntime(store) + const kill = vi.fn(() => true) + const closeTerminalTab = vi.fn(async () => {}) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn(), + closeTerminalTab + }) + const stopAndWait = vi.fn(async () => { + await runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]).toBeDefined() + return true + }) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + createCloseContinuityGraphFixture({ + runtime, + ptyId: PTY_ID, + publishMobileSurface: true, + getSession: () => store.getWorkspaceSession(), + setSession: (session) => store.setWorkspaceSession(session), + markSiblingPtyIncluded: () => {} + }).syncFixtureGraph() + const command = { + type: 'closePane', + workspace: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID + } as const + await expect(runtime.applyLayoutCommand(command)).resolves.toMatchObject({ + ok: false, + code: 'last_pane' + }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + expect(source.presentation).toBeDefined() + expect(source.incarnationId).toBe(INCARNATION_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Missing authenticated original') + } + const apply = vi.spyOn(store, 'applyLayoutCommand') + const forget = vi.spyOn(ClientSessionTabSelectionStore.prototype, 'forgetTabs') + await expect( + runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) + ).resolves.toMatchObject({ ptyKilled: true }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(apply).toHaveResolvedWith({ ok: true, result: { closed: [TAB_ID] } }) + } + expect(forget).toHaveBeenCalledWith(WORKTREE_ID, [TAB_ID]) + expect(stopAndWait).toHaveBeenCalledExactlyOnceWith( + PTY_ID, + expect.objectContaining({ expectedIncarnationId: INCARNATION_ID }) + ) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).toHaveBeenCalledExactlyOnceWith(TAB_ID, { + localPtyTeardownOwnedExternally: true, + originalSurface: source.presentation.identity + }) + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(readState().workspaceSession.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() +}) diff --git a/src/main/runtime/orca-runtime-report-pty-spawn-commit.ts b/src/main/runtime/orca-runtime-report-pty-spawn-commit.ts index b0aa9a6a3dca..3ef23174150b 100644 --- a/src/main/runtime/orca-runtime-report-pty-spawn-commit.ts +++ b/src/main/runtime/orca-runtime-report-pty-spawn-commit.ts @@ -1,3 +1,5 @@ +import type { TerminalCreateOptions } from './runtime-terminal-contracts' + export function createIdempotentCallback(callback?: () => void): () => void { let reported = false return () => { @@ -8,3 +10,16 @@ export function createIdempotentCallback(callback?: () => void): () => void { callback?.() } } + +/** Observe the admitted dispatch using the identity and effective recipe sent to spawn. */ +export function reportTerminalSpawnDispatched( + opts: TerminalCreateOptions, + launch: () => NonNullable< + Parameters>[0] + > +): () => void { + return () => { + opts.beforePtySpawn?.() + opts.onPtySpawnDispatched?.(launch()) + } +} diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 2b5bb462b20c..453f768b321e 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { prepareAdoptedTranscript } from '../native-chat/agent-session-wire/structured-agent-session-adopted-import' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' @@ -10,8 +12,6 @@ vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) import { OrcaRuntimeService } from './orca-runtime' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts index c9c2b190343d..92a84ef726f6 100644 --- a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -54,10 +54,7 @@ describe('terminal rename before renderer graph hydration', () => { expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) expect(renameTerminal).toHaveBeenCalledWith('host-tab', title) - // Main-owned last-pane exit closes its tab; model restart with a fresh runtime over the saved session instead. - if (!ORCA_LAYOUT_OWNER_IS_MAIN) { - runtime.onPtyExit('omp-initial-pty', 0) - } + // A permanent last-pane exit closes its tab; restart reuses the retained saved session. const restored = new OrcaRuntimeService(checkedStore) restored.setPtyController({ spawn: vi.fn(committingSpawn(restored, async () => ({ id: 'omp-replacement-pty' }))), diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 1c406e77d481..e7e3c42b60cc 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -1,6 +1,7 @@ import { withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -98,7 +99,7 @@ function createHarness( ) { let session = persistedSession(includeSource) const connectionId = options.connectionId ?? null - const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local' + const ownerHostId: ExecutionHostId = connectionId ? `ssh:${connectionId}` : 'local' const requestedSessionHostIds: (string | undefined)[] = [] // Exclude fixture spawn-commit reads from split-read assertions. let committingPane = false @@ -146,7 +147,8 @@ function createHarness( (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => new Promise<{ id: string; incarnationId?: string }>((resolve) => { resolveSpawn = resolve - }) + }), + ownerHostId ) ) : vi.fn( @@ -154,9 +156,17 @@ function createHarness( runtime, async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({ id: SPLIT_PTY_ID - }) + }), + ownerHostId ) ) + let afterSpawnCommit: (() => void) | undefined + const committedSpawn = spawn.getMockImplementation()! + spawn.mockImplementation(async (args) => { + const result = await committedSpawn(args) + afterSpawnCommit?.() + return result + }) Object.assign(runtime, { resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ id: WORKTREE_ID, @@ -248,6 +258,9 @@ function createHarness( getSession: () => session, getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID), requestedSessionHostIds, + afterSpawnCommit: (run: () => void) => { + afterSpawnCommit = run + }, replaceSourceIncarnation: (incarnationId: string) => registerSource(incarnationId), replacePersistedSourceIncarnation: (incarnationId: string) => { session = { @@ -510,7 +523,7 @@ describe('remote runtime terminal split authority', () => { const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) - harness.replacePersistedSourceIncarnation('persisted-after') + harness.afterSpawnCommit(() => harness.replacePersistedSourceIncarnation('persisted-after')) harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') @@ -549,7 +562,7 @@ describe('remote runtime terminal split authority', () => { const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) - harness.replacePersistedSourceIncarnation('persisted-after') + harness.afterSpawnCommit(() => harness.replacePersistedSourceIncarnation('persisted-after')) harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index c2538525f32f..faf463b0ce17 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -11,9 +11,12 @@ import { getTerminalPasteIngestMs } from '../../shared/agent-prompt-injection' import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' import { makePaneKey } from '../../shared/stable-pane-id' import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission' +import { applyPtyBinding } from '../persistence/loading-store/pty-binding-session-update' +import { ptyBindingIsRefused } from '../persistence/loading-store/pty-binding-refusals' import { resolveHostId } from '../persistence/loading-store/session-host-partitions' import { toComparableRelaySshPtyId } from '../../shared/ssh-pty-id' import { spawnedPtyEnv } from './spawned-pty-env-fixtures' @@ -569,7 +572,7 @@ function makeHeadlessTerminalLayout( function makeRuntimeStoreWithWorkspaceSession( initialSession: WorkspaceSessionState, // Session fixtures must answer only their host partition so hardcoded host ids fail. - ownerHostId = 'local' + ownerHostId: ExecutionHostId = 'local' ): { runtimeStore: typeof store & { admitTerminalPane: ReturnType['admitTerminalPane'] @@ -626,30 +629,16 @@ function makeRuntimeStoreWithWorkspaceSession( if (!args) { return false } - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } - } + const worktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + const paneKey = makePaneKey(args.tabId, args.leafId) + if ( + ptyBindingIsRefused(args, session, worktreeId, paneKey, [ + { hostId: ownerHostId, session } + ]) + ) { + return false } + applyPtyBinding(args, session, worktreeId, paneKey) return true }) }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts index ea50773d0a66..46a0146c322e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' import { bindPaneFirst, committingSpawn, registerBoundPty } from '../bind-first-pane.test-fixture' import { OrcaRuntimeService, @@ -28,7 +29,7 @@ describe('OrcaRuntimeService', () => { function makeParkedSessionStore( origin: SleepingAgentSessionRecord['origin'] | undefined, overrides: Partial = {}, - ownerHostId = 'local' + ownerHostId: ExecutionHostId = 'local' ) { return makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume-claim-lifetime.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume-claim-lifetime.spec.ts new file mode 100644 index 000000000000..bc45cf8a9780 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume-claim-lifetime.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import { TEST_WORKTREE_ID } from '../orca-runtime-test-fixtures.spec' +import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' +import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' +import { + PANE_KEY, + MUTATION_ID, + makeSleptWorkspace, + makeRuntime, + sessionRow, + resumeSpawns, + storedRecord +} from './targeted-agent-resume.test-fixture' + +describe('resumeAgentSessionInWorkspace retries, cancellation and a failed publication', () => { + it('reconciles the same request after its own hook arrives', async () => { + let rows: AgentStatusIpcPayload[] = [] + const terminalHandle = deriveRemoteRuntimeTerminalCreateHandle( + 'agent-session-resume', + TEST_WORKTREE_ID, + MUTATION_ID + ) + const { resume, spawn } = makeRuntime(makeSleptWorkspace(), { + rows: () => rows, + checkHookAgentPresence: async () => 'live', + listProcesses: async () => + spawn.mock.calls.length > 0 + ? [ + { + id: 'resumed-pty', + cwd: '', + title: 'claude', + terminalHandle, + worktreeId: TEST_WORKTREE_ID + } + ] + : [] + }) + const first = await resume() + if (!first.ok) { + throw new Error(first.reason) + } + rows = [sessionRow(first.paneKey, Date.now() + 1)] + await expect(resume()).resolves.toEqual(first) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('honors cancellation during create inventory lookup before dispatch', async () => { + const controller = new AbortController() + const { resume, spawn } = makeRuntime(makeSleptWorkspace(), { + listProcesses: async () => { + controller.abort() + return [] + } + }) + await expect(resume({ signal: controller.signal })).resolves.toMatchObject({ ok: false }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('settles a failed post-spawn publication when that PTY exits', async () => { + const { runtime, resume } = makeRuntime(makeSleptWorkspace()) + const unsubscribe = runtime.onMobileSessionTabsChanged(() => { + throw new Error('publication failed') + }) + await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) + unsubscribe() + runtime.onPtyExit('resumed-pty', 0) + await expect(resume({ mutationId: 'next-delivery' })).resolves.toMatchObject({ ok: true }) + }) +}) + +describe('resumeAgentSessionInWorkspace claim lifetime', () => { + it('releases the hold after a proven exit before registration', async () => { + let first = true + const { runtime, resume } = makeRuntime(makeSleptWorkspace(), { + spawn: async () => { + if (first) { + first = false + runtime.beginPtyRegistration('resumed-pty') + runtime.onPtyExit('resumed-pty', 0) + } + return { id: 'resumed-pty' } + } + }) + await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) + await expect(resume({ mutationId: 'after-proven-exit' })).resolves.toMatchObject({ ok: true }) + }) + + it('does not settle a lost reply using another caller terminal in the same pane', async () => { + let rejectFirst!: (error: Error) => void + let started!: () => void + const start = new Promise((resolve) => { + started = resolve + }) + let calls = 0 + const { runtime, resume, spawn } = makeRuntime(makeSleptWorkspace(), { + spawn: async () => { + calls += 1 + if (calls === 1) { + started() + return new Promise<{ id: string }>((_resolve, reject) => { + rejectFirst = reject + }) + } + return { id: `other-pty-${calls}` } + } + }) + const first = resume() + await start + const args = spawn.mock.calls[0]?.[0] + if ( + !args || + typeof args !== 'object' || + !('tabId' in args) || + !('leafId' in args) || + typeof args.tabId !== 'string' || + typeof args.leafId !== 'string' + ) { + throw new Error('missing pane identity') + } + const other = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: args.tabId, + leafId: args.leafId, + command: 'echo another caller', + focus: false + }) + rejectFirst(new Error('lost spawn reply')) + await expect(first).resolves.toEqual({ ok: false, reason: 'create_failed' }) + if (!other.ptyId) { + throw new Error('missing other PTY') + } + runtime.onPtyExit(other.ptyId, 0) + await expect(resume({ mutationId: 'other-delivery' })).resolves.toEqual({ + ok: false, + reason: 'held_elsewhere' + }) + }) + + it('does not settle an ambiguous resume using an earlier exited shell in its pane', async () => { + const seed = `${TEST_WORKTREE_ID}\0${MUTATION_ID}` + const tabId = deterministicAgentSessionUuid(`agent-session-resume-tab\0${seed}`) + const leafId = deterministicAgentSessionUuid(`agent-session-resume-leaf\0${seed}`) + let firstInventory = true + let spawns = 0 + const { runtime, resume } = makeRuntime(makeSleptWorkspace(), { + listProcesses: async () => { + if (firstInventory) { + firstInventory = false + const other = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId, + leafId, + command: 'echo another caller', + focus: false + }) + if (!other.ptyId) { + throw new Error('missing shell PTY') + } + runtime.onPtyExit(other.ptyId, 0) + } + return [] + }, + spawn: async () => { + spawns += 1 + if (spawns === 2) { + throw new Error('resume started but reply lost') + } + return { id: `sequential-pty-${spawns}` } + } + }) + await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) + await expect(resume({ mutationId: 'next-delivery' })).resolves.toEqual({ + ok: false, + reason: 'held_elsewhere' + }) + }) +}) + +it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'hands a numbered-record resume claim to the stable pane hook', + async () => { + const fixture = makeSleptWorkspace() + const record = storedRecord(fixture) + if (!record) { + throw new Error('missing sleep record') + } + fixture.setSession({ + ...fixture.getSession(), + sleepingAgentSessionsByPaneKey: { + 'host-tab:1': { ...record, paneKey: 'host-tab:1', state: 'done' } + } + }) + let rows: AgentStatusIpcPayload[] = [] + let spawned = 0 + const { resume, openOldPane, spawn } = makeRuntime(fixture, { + rows: () => rows, + checkHookAgentPresence: async () => 'exited', + spawn: async () => ({ id: `numbered-resume-${++spawned}` }) + }) + await openOldPane() + expect(resumeSpawns(spawn)).toBe(1) + rows = [sessionRow(PANE_KEY, Date.now() + 1)] + await expect(resume()).resolves.toMatchObject({ ok: true }) + } +) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index 04b7d9c2f157..975f2ba600ee 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -1,21 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' -import type { SleepingAgentSessionRecord } from '../orca-runtime-test-mocks.spec' -import { - HEADLESS_LEAF_ID, - HEADLESS_SECOND_LEAF_ID, - TEST_WINDOW_ID, - TEST_WORKTREE_ID, - makeRuntimeStoreWithWorkspaceSession, - makeWorkspaceSessionWithHeadlessTerminal -} from '../orca-runtime-test-fixtures.spec' +import type { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { HEADLESS_SECOND_LEAF_ID, TEST_WORKTREE_ID } from '../orca-runtime-test-fixtures.spec' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import type { TerminalLaunchResult } from '../../../shared/agent-session-resume' -import type { PtyProcessInfo } from '../../providers/pty-provider-contract' -import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' -import type { RuntimePtyController } from '../runtime-pty-controller-contract' import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { AgentSessionRecordStore } from '../agent-session-record-store' import { @@ -26,35 +14,21 @@ import { chooseSavedResumeFreshLaunch, savedResumeResult } from '../../ipc/pty/host-env/saved-resume-admission' +import { + PANE_KEY, + SESSION, + MUTATION_ID, + makeSleptWorkspace, + makeRuntime, + sessionRow, + spawnedCommands, + resumeSpawns, + storedRecord, + type Fixture +} from './targeted-agent-resume.test-fixture' -const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` -const SESSION = { key: 'session_id', id: 'provider-session-1' } as const -const MUTATION_ID = '["dev","thread-1","delivery-1"]' const PANE_OWNED = { origin: 'worktree-sleep', state: 'done', restoreOnTabOpenOnly: true } -function makeSleptWorkspace() { - const record: SleepingAgentSessionRecord = { - paneKey: PANE_KEY, - tabId: 'host-tab', - worktreeId: TEST_WORKTREE_ID, - agent: 'claude', - providerSession: SESSION, - prompt: 'do the thing', - state: 'working', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep', - launchConfig: { agentArgs: '--permission-mode plan', agentEnv: {} } - } - return makeRuntimeStoreWithWorkspaceSession( - makeWorkspaceSessionWithHeadlessTerminal({ - sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record } - }) - ) -} - -type Fixture = ReturnType - type DurableMutationStore = { runDurableMutation: (mutate: unknown) => Promise } function isDurableMutationStore( @@ -76,88 +50,6 @@ function hasWorkspaceScopeResolver( ) } -function sessionRow(paneKey: string, receivedAt = 0): AgentStatusIpcPayload { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guards read only paneKey, providerSession and the observation clocks. - return { paneKey, providerSession: SESSION, receivedAt } as never -} - -function makeRuntime( - fixture: Fixture, - opts: { - spawn?: NonNullable - rows?: () => AgentStatusIpcPayload[] - checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null> - listProcesses?: () => Promise - subscribeAgentProviderSessionChanges?: ( - listener: (sessions: readonly { paneKey: string; sessionId: string }[]) => void - ) => () => void - } = {} -) { - const spawn = vi.fn>( - opts.spawn ?? (() => Promise.resolve({ id: 'resumed-pty' })) - ) - const runtime = new OrcaRuntimeService(fixture.runtimeStore, undefined, { - getDesktopWindowStatus: () => 'openable', - ...(opts.rows ? { getAgentProviderSessionSnapshot: opts.rows } : {}), - ...(opts.checkHookAgentPresence ? { checkHookAgentPresence: opts.checkHookAgentPresence } : {}), - ...(opts.subscribeAgentProviderSessionChanges - ? { subscribeAgentProviderSessionChanges: opts.subscribeAgentProviderSessionChanges } - : {}) - }) - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null, - listProcesses: opts.listProcesses ?? (async () => []) - }) - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - electronMocks.BrowserWindow.fromId.mockReturnValue(null) - const attachWindow = (webContents?: { send: (...args: unknown[]) => void }): void => { - runtime.attachWindow(TEST_WINDOW_ID) - runtime.markGraphReady(TEST_WINDOW_ID) - electronMocks.BrowserWindow.fromId.mockReturnValue({ - isDestroyed: () => false, - ...(webContents ? { webContents } : {}) - }) - } - const resume = ( - overrides: { - captureHostId?: 'local' | `ssh:${string}` - mutationId?: string - signal?: AbortSignal - } = {} - ) => - runtime.resumeAgentSessionInWorkspace(TEST_WORKTREE_ID, { - agent: 'claude', - providerSession: SESSION, - captureHostId: overrides.captureHostId ?? 'local', - mutationId: overrides.mutationId ?? MUTATION_ID, - signal: overrides.signal - }) - const openOldPane = () => - runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', HEADLESS_LEAF_ID, { - notifyClients: false, - navigation: 'caller', - intent: 'user' - }) - return { runtime, spawn, attachWindow, resume, openOldPane } -} - -function spawnedCommands(spawn: ReturnType): string[] { - return spawn.mock.calls.map(([args]: unknown[]) => - typeof args === 'object' && args !== null && 'command' in args ? String(args.command ?? '') : '' - ) -} - -function resumeSpawns(spawn: ReturnType): number { - return spawnedCommands(spawn).filter((command) => command.includes('provider-session-1')).length -} - -function storedRecord(fixture: Fixture) { - return fixture.getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] -} - describe('resumeAgentSessionInWorkspace', () => { it.each(['live', 'worktree-sleep'] as const)( 'releases a %s saved resume claim when actual admission creates a plain shell', @@ -179,6 +71,7 @@ describe('resumeAgentSessionInWorkspace', () => { chat.providerHandleChain[0]!.handle.nativeId = SESSION.id let nativeOpen = true const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.getRecord = (sessionId) => (sessionId === chat.sessionId ? chat : null) store.listConversations = () => [agentSessionConversationView(chat)] store.getVisibleSessionTabIndex = () => ({ present: true, @@ -682,194 +575,3 @@ describe('resumeAgentSessionInWorkspace', () => { await expect(resume()).resolves.toMatchObject({ ok: true }) }) }) - -describe('resumeAgentSessionInWorkspace retries, cancellation and a failed publication', () => { - it('reconciles the same request after its own hook arrives', async () => { - let rows: AgentStatusIpcPayload[] = [] - const terminalHandle = deriveRemoteRuntimeTerminalCreateHandle( - 'agent-session-resume', - TEST_WORKTREE_ID, - MUTATION_ID - ) - const { resume, spawn } = makeRuntime(makeSleptWorkspace(), { - rows: () => rows, - checkHookAgentPresence: async () => 'live', - listProcesses: async () => - spawn.mock.calls.length > 0 - ? [ - { - id: 'resumed-pty', - cwd: '', - title: 'claude', - terminalHandle, - worktreeId: TEST_WORKTREE_ID - } - ] - : [] - }) - const first = await resume() - if (!first.ok) { - throw new Error(first.reason) - } - rows = [sessionRow(first.paneKey, Date.now() + 1)] - await expect(resume()).resolves.toEqual(first) - expect(spawn).toHaveBeenCalledOnce() - }) - - it('honors cancellation during create inventory lookup before dispatch', async () => { - const controller = new AbortController() - const { resume, spawn } = makeRuntime(makeSleptWorkspace(), { - listProcesses: async () => { - controller.abort() - return [] - } - }) - await expect(resume({ signal: controller.signal })).resolves.toMatchObject({ ok: false }) - expect(spawn).not.toHaveBeenCalled() - }) - - it('settles a failed post-spawn publication when that PTY exits', async () => { - const { runtime, resume } = makeRuntime(makeSleptWorkspace()) - const unsubscribe = runtime.onMobileSessionTabsChanged(() => { - throw new Error('publication failed') - }) - await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) - unsubscribe() - runtime.onPtyExit('resumed-pty', 0) - await expect(resume({ mutationId: 'next-delivery' })).resolves.toMatchObject({ ok: true }) - }) -}) - -describe('resumeAgentSessionInWorkspace claim lifetime', () => { - it('releases the hold after a proven exit before registration', async () => { - let first = true - const { runtime, resume } = makeRuntime(makeSleptWorkspace(), { - spawn: async () => { - if (first) { - first = false - runtime.beginPtyRegistration('resumed-pty') - runtime.onPtyExit('resumed-pty', 0) - } - return { id: 'resumed-pty' } - } - }) - await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) - await expect(resume({ mutationId: 'after-proven-exit' })).resolves.toMatchObject({ ok: true }) - }) - - it('does not settle a lost reply using another caller terminal in the same pane', async () => { - let rejectFirst!: (error: Error) => void - let started!: () => void - const start = new Promise((resolve) => { - started = resolve - }) - let calls = 0 - const { runtime, resume, spawn } = makeRuntime(makeSleptWorkspace(), { - spawn: async () => { - calls += 1 - if (calls === 1) { - started() - return new Promise<{ id: string }>((_resolve, reject) => { - rejectFirst = reject - }) - } - return { id: `other-pty-${calls}` } - } - }) - const first = resume() - await start - const args = spawn.mock.calls[0]?.[0] - if ( - !args || - typeof args !== 'object' || - !('tabId' in args) || - !('leafId' in args) || - typeof args.tabId !== 'string' || - typeof args.leafId !== 'string' - ) { - throw new Error('missing pane identity') - } - const other = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { - tabId: args.tabId, - leafId: args.leafId, - command: 'echo another caller', - focus: false - }) - rejectFirst(new Error('lost spawn reply')) - await expect(first).resolves.toEqual({ ok: false, reason: 'create_failed' }) - if (!other.ptyId) { - throw new Error('missing other PTY') - } - runtime.onPtyExit(other.ptyId, 0) - await expect(resume({ mutationId: 'other-delivery' })).resolves.toEqual({ - ok: false, - reason: 'held_elsewhere' - }) - }) - - it('does not settle an ambiguous resume using an earlier exited shell in its pane', async () => { - const seed = `${TEST_WORKTREE_ID}\0${MUTATION_ID}` - const tabId = deterministicAgentSessionUuid(`agent-session-resume-tab\0${seed}`) - const leafId = deterministicAgentSessionUuid(`agent-session-resume-leaf\0${seed}`) - let firstInventory = true - let spawns = 0 - const { runtime, resume } = makeRuntime(makeSleptWorkspace(), { - listProcesses: async () => { - if (firstInventory) { - firstInventory = false - const other = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { - tabId, - leafId, - command: 'echo another caller', - focus: false - }) - if (!other.ptyId) { - throw new Error('missing shell PTY') - } - runtime.onPtyExit(other.ptyId, 0) - } - return [] - }, - spawn: async () => { - spawns += 1 - if (spawns === 2) { - throw new Error('resume started but reply lost') - } - return { id: `sequential-pty-${spawns}` } - } - }) - await expect(resume()).resolves.toEqual({ ok: false, reason: 'create_failed' }) - await expect(resume({ mutationId: 'next-delivery' })).resolves.toEqual({ - ok: false, - reason: 'held_elsewhere' - }) - }) -}) - -it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( - 'hands a numbered-record resume claim to the stable pane hook', - async () => { - const fixture = makeSleptWorkspace() - const record = storedRecord(fixture) - if (!record) { - throw new Error('missing sleep record') - } - fixture.setSession({ - ...fixture.getSession(), - sleepingAgentSessionsByPaneKey: { - 'host-tab:1': { ...record, paneKey: 'host-tab:1', state: 'done' } - } - }) - let rows: AgentStatusIpcPayload[] = [] - let spawned = 0 - const { resume, openOldPane, spawn } = makeRuntime(fixture, { - rows: () => rows, - checkHookAgentPresence: async () => 'exited', - spawn: async () => ({ id: `numbered-resume-${++spawned}` }) - }) - await openOldPane() - expect(resumeSpawns(spawn)).toBe(1) - rows = [sessionRow(PANE_KEY, Date.now() + 1)] - await expect(resume()).resolves.toMatchObject({ ok: true }) - } -) diff --git a/src/main/runtime/orca-runtime-tests/targeted-agent-resume.test-fixture.ts b/src/main/runtime/orca-runtime-tests/targeted-agent-resume.test-fixture.ts new file mode 100644 index 000000000000..5587adc7a716 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/targeted-agent-resume.test-fixture.ts @@ -0,0 +1,121 @@ +import { vi } from 'vitest' +import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' +import type { SleepingAgentSessionRecord } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WINDOW_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { PtyProcessInfo } from '../../providers/pty-provider-contract' +import type { RuntimePtyController } from '../runtime-pty-controller-contract' + +export const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +export const SESSION = { key: 'session_id', id: 'provider-session-1' } as const +export const MUTATION_ID = '["dev","thread-1","delivery-1"]' +export function makeSleptWorkspace() { + const record: SleepingAgentSessionRecord = { + paneKey: PANE_KEY, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: SESSION, + prompt: 'do the thing', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep', + launchConfig: { agentArgs: '--permission-mode plan', agentEnv: {} } + } + return makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record } + }) + ) +} + +export type Fixture = ReturnType + +export function sessionRow(paneKey: string, receivedAt = 0): AgentStatusIpcPayload { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guards read only paneKey, providerSession and the observation clocks. + return { paneKey, providerSession: SESSION, receivedAt } as never +} + +export function makeRuntime( + fixture: Fixture, + opts: { + spawn?: NonNullable + rows?: () => AgentStatusIpcPayload[] + checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null> + listProcesses?: () => Promise + subscribeAgentProviderSessionChanges?: ( + listener: (sessions: readonly { paneKey: string; sessionId: string }[]) => void + ) => () => void + } = {} +) { + const spawn = vi.fn>( + opts.spawn ?? (() => Promise.resolve({ id: 'resumed-pty' })) + ) + const runtime = new OrcaRuntimeService(fixture.runtimeStore, undefined, { + getDesktopWindowStatus: () => 'openable', + ...(opts.rows ? { getAgentProviderSessionSnapshot: opts.rows } : {}), + ...(opts.checkHookAgentPresence ? { checkHookAgentPresence: opts.checkHookAgentPresence } : {}), + ...(opts.subscribeAgentProviderSessionChanges + ? { subscribeAgentProviderSessionChanges: opts.subscribeAgentProviderSessionChanges } + : {}) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: opts.listProcesses ?? (async () => []) + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue(null) + const attachWindow = (webContents?: { send: (...args: unknown[]) => void }): void => { + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + ...(webContents ? { webContents } : {}) + }) + } + const resume = ( + overrides: { + captureHostId?: 'local' | `ssh:${string}` + mutationId?: string + signal?: AbortSignal + } = {} + ) => + runtime.resumeAgentSessionInWorkspace(TEST_WORKTREE_ID, { + agent: 'claude', + providerSession: SESSION, + captureHostId: overrides.captureHostId ?? 'local', + mutationId: overrides.mutationId ?? MUTATION_ID, + signal: overrides.signal + }) + const openOldPane = () => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', HEADLESS_LEAF_ID, { + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return { runtime, spawn, attachWindow, resume, openOldPane } +} + +export function spawnedCommands(spawn: ReturnType): string[] { + return spawn.mock.calls.map(([args]: unknown[]) => + typeof args === 'object' && args !== null && 'command' in args ? String(args.command ?? '') : '' + ) +} + +export function resumeSpawns(spawn: ReturnType): number { + return spawnedCommands(spawn).filter((command) => command.includes('provider-session-1')).length +} + +export function storedRecord(fixture: Fixture) { + return fixture.getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4496ae60547d..6a0ec57905ed 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -96,6 +96,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-shell-selection.spec') await import('./orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec') await import('./orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec') await import('./orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec') +await import('./orca-runtime-tests/mobile-session-tabs-targeted-agent-resume-claim-lifetime.spec') await import('./orca-runtime-tests/mobile-session-tabs-headless-wake-report.spec') await import('./orca-runtime-tests/exit-retirement-activation.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2d74248d1461..076b0a6e5516 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,3 +1,6 @@ +import { withPublishedSessionTabOpenPlacements } from './session-tabs/session-tab-open-placement' +import { parsePaneKey } from '../../shared/stable-pane-id' +import { noteAgentLaunchPaneAccepted } from '../agent-launch/agent-launch-pane-attachment' import { conversationRecordReader } from './orchestration/structured-session-lineage' import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' @@ -8,7 +11,6 @@ import { } from './sidebar-inputs/orca-runtime-sidebar-temporal' import { OrcaRuntimeWithAgentTransitions } from './orca-runtime-agent-transitions' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import type { AgentLaunchTabPublished, @@ -35,8 +37,7 @@ import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-r import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' -import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey' -import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' +import type { WindowLayoutCommand, LayoutCommandRunOptions } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' import { installStructuredAgentAvailability, @@ -193,6 +194,21 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { } /** Retain final verdicts, clear settled ones, and remove withdrawn panes without removing split siblings. */ + noteAcceptedAgentLaunchPane(paneKey: string, operationId?: string): void { + noteAgentLaunchPaneAccepted(paneKey) + const pane = parsePaneKey(paneKey) + if (!pane || !this.supportsConditionalSwitchProjection()) { + return + } + this.notifier?.agentLaunchPaneVerdict?.({ + worktreeId: '', + ...pane, + operationId, + accepted: true, + verdict: { kind: 'proceed' } + }) + } + reportAgentLaunchPaneVerdict( pane: AgentLaunchPaneAddress, verdict: AgentLaunchPaneVerdict @@ -228,6 +244,11 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { store: () => this.store, homeHostId: (key) => this.tryGetWorkspaceSessionHostIdForWorktree(key), liveTitle: (ptyId) => this.getTrackedDisplayTitleForPty(ptyId) || null, + withOpenPlacements: (layout) => + withPublishedSessionTabOpenPlacements( + layout, + this.mobileSessionTabsByWorktree.get(layout.worktreeId) + ), ...this.paneFactSources() }) @@ -247,26 +268,13 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { return this.workspaceLayoutStream } - /** Apply a window command to the partitions that hold its tab. */ - async applyLayoutCommand(command: WindowLayoutCommand): Promise { - // Wait for live terminal placement before creating the first workspace surface. - const placing = this.livePlacementSettledFor(command) - const apply = (): Promise => - applyWindowLayoutCommand( - this.requireStore(), - (worktreeId, tabId) => - tabId !== null - ? this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) - : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), - command, - (move) => rekeyMovedPaneOwners(this, move), - this.layoutCommandRun(command) - ) - // Order sleep/wake after previously accepted hook statuses. - const reply = await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) - ? this.inResumeRecordOrder(apply) - : (placing?.then(apply) ?? apply())) - // The frame precedes the reply so the window can wait for its visible change. + /** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */ + async applyLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise { + const reply = await this.applyOwnedWindowLayoutCommand(command, options) + // Sent before the reply, so the window can wait for the frame that shows the change. return { ...reply, frameSeq: this.workspaceLayoutStream.settledSeq() } } diff --git a/src/main/runtime/orchestration/chat-assignee.ts b/src/main/runtime/orchestration/chat-assignee.ts index 5f0d77b5f8cc..e8aa4e318edc 100644 --- a/src/main/runtime/orchestration/chat-assignee.ts +++ b/src/main/runtime/orchestration/chat-assignee.ts @@ -96,3 +96,28 @@ export function exitedChatDispatchesForSession( } ) } + +/** Direct assignments outlive a provider child; /clear keeps their original assignee address. */ +export function chatAssigneeOwesWork( + db: OrchestrationDb | null, + sessionId: string, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): boolean { + if (!db || !store) { + return false + } + const rows = db.db + .prepare(`SELECT DISTINCT assignee_handle FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') AND substr(assignee_handle, 1, ?) = ?`) + .all(ORCA_SESSION_ADDRESS_PREFIX.length, ORCA_SESSION_ADDRESS_PREFIX) + return rows.some((row) => { + const assignee = + typeof row === 'object' && + row !== null && + 'assignee_handle' in row && + typeof row.assignee_handle === 'string' + ? chatAssigneeSessionId(row.assignee_handle) + : null + return assignee !== null && lineageLiveSession(store, assignee)?.sessionId === sessionId + }) +} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 1ac6bb2940f5..a6841d82a149 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -1,5 +1,4 @@ -/** Resolve structured-owned mail to a session turn, covering Run, session, Dispatch and worker-handle addresses. */ - +import { resolveStructuredWorkerAuthority } from '../structured-worker-authority' import { ORCA_SESSION_ADDRESS_PREFIX, isOrcaSessionId, @@ -19,6 +18,7 @@ import { } from './structured-session-lineage' import type { DispatchContextRow, RunRow } from './types' import { chatAssigneeSessionId } from './chat-assignee' +/** Resolve structured-owned mail to a session turn, covering Run, session, Dispatch and worker-handle addresses. */ /** A handle-less coordinator uses its generation-current session id; structured workers resolve by handle. */ export function handleLessCoordinatorSessionId( @@ -109,3 +109,20 @@ export function structuredSessionIdleEdgeMailboxes( } return db ? structuredSessionOwnedMailboxes(sessionId, db) : [] } + +export function resolveStructuredCoordinatorMailboxTarget( + db: OrchestrationDb | null, + runId: string +): StructuredPointerTarget | null { + const run = db?.getRun?.(runId) + const sessionId = run ? handleLessCoordinatorSessionId(run) : null + if (sessionId) { + return structuredSessionMailTarget(sessionId, db) + } + const coordinator = run?.coordinator_handle + if (!coordinator) { + return null + } + const workerSessionId = resolveStructuredWorkerAuthority(coordinator, db)?.running.sessionId + return workerSessionId ? { sessionId: workerSessionId, dispatchId: null } : null +} diff --git a/src/main/runtime/orchestration/structured-worker-switch-stop.test.ts b/src/main/runtime/orchestration/structured-worker-switch-stop.test.ts new file mode 100644 index 000000000000..15d32a517289 --- /dev/null +++ b/src/main/runtime/orchestration/structured-worker-switch-stop.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { OrchestrationDb } from './db' +import { captureStructuredWorkerSwitchStop } from './structured-worker-switch-stop' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { setStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_SESSION as id, + REST_TEST_CALLER as caller, + type RestTestRig +} from '../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' + +let rig: RestTestRig +let db: OrchestrationDb +let deadline: ReturnType +const forgetStructuredSessionMail = vi.fn() +beforeEach(async () => { + db = new OrchestrationDb(':memory:') + deadline = agentSessionSwitchDeadline() + forgetStructuredSessionMail.mockClear() + rig = await createRestTestRig({ + captureSessionSwitchStop: (sessionId) => + captureStructuredWorkerSwitchStop(sessionId, db, 'test-runtime', { + forgetStructuredSessionMail + }) + }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + structuredWorkerIdentities.clear() + deadline.dispose() + await rig.dispose() + db.close() +}) + +it.each([false, true])( + 'settles worker Stop only after proven physical exit=%s and releases ordinary mail redrive', + async (proven) => { + await foundRestTestChat(rig) + const identity = structuredWorkerIdentities.register({ + handle: mintStructuredWorkerHandle(), + sessionId: id, + agent: 'codex', + paneKey: mintStructuredWorkerPaneKey(id), + processIncarnation: structuredWorkerProcessIncarnation(id), + worktreeId: 'workspace', + hostScope: { kind: 'local', hostId: 'local' } + }) + const task = db.createTask({ runId: 'run_legacy_local', spec: 'Captured worker turn' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: identity.handle, + paneKey: identity.paneKey, + processIncarnation: identity.processIncarnation, + worktreeId: identity.worktreeId, + effects: [], + setupState: 'not_configured', + hostScope: JSON.stringify(identity.hostScope), + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(dispatch.id) + const entered = Promise.withResolvers() + const exit = Promise.withResolvers() + const close = rig.adapter.closeSession.getMockImplementation()! + rig.adapter.closeSession.mockImplementation(async (...args) => { + entered.resolve() + return (await exit.promise) ? close(...args) : false + }) + const stopping = rig.host.stopForSwitch(id, caller, deadline) + const observed = stopping.then( + () => true, + () => false + ) + await entered.promise + exit.resolve(proven) + expect(await observed).toBe(proven) + expect(db.getWorkerDispatch(dispatch.id)?.state).toBe(proven ? 'stopped' : 'stop_unknown') + expect(forgetStructuredSessionMail).toHaveBeenCalledWith(id) + expect(db.getDispatchContextById(dispatch.id)?.status).toBe(proven ? 'failed' : 'dispatched') + } +) diff --git a/src/main/runtime/orchestration/structured-worker-switch-stop.ts b/src/main/runtime/orchestration/structured-worker-switch-stop.ts new file mode 100644 index 000000000000..e3e35ea3d00f --- /dev/null +++ b/src/main/runtime/orchestration/structured-worker-switch-stop.ts @@ -0,0 +1,54 @@ +import type { OrchestrationDb } from './db' +import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' +import { releaseStructuredWorkerSession } from '../rpc/methods/orchestration-structured-worker-session' + +/** Capture the ordinary worker Stop before closing; release redrive even when exit is unknown. */ +export function captureStructuredWorkerSwitchStop( + sessionId: string, + db: OrchestrationDb | null, + runtimeEpoch: string, + runtime: { forgetStructuredSessionMail(sessionId: string): void } +): ((proven: boolean) => void) | undefined { + const identity = resolveStructuredWorkerIdentityForSession(sessionId, db) + const resource = identity && db?.getWorkerTerminalResourceByHandle(identity.handle) + const dispatchId = resource?.owner_dispatch_id + if ( + !identity || + !dispatchId || + !db || + !db.isDispatchProcessCurrent({ + dispatchId, + paneKey: identity.paneKey, + processIncarnation: identity.processIncarnation + }) + ) { + return undefined + } + const worker = db.getWorkerDispatch(dispatchId) + if (worker && ['ready', 'start_unknown'].includes(worker.state)) { + db.beginWorkerStop(dispatchId, runtimeEpoch) + } + return (proven) => { + try { + if ( + db.isDispatchProcessCurrent({ + dispatchId, + paneKey: identity.paneKey, + processIncarnation: identity.processIncarnation + }) && + db.getWorkerDispatch(dispatchId)?.state === 'stopping' + ) { + if (proven) { + db.settleWorkerStop(dispatchId) + } else { + db.markWorkerStopUnknown( + dispatchId, + 'The agent stop could not be confirmed. Retry Stop to check its exit.' + ) + } + } + } finally { + releaseStructuredWorkerSession(dispatchId, runtime, identity.sessionId) + } + } +} diff --git a/src/main/runtime/orchestration/terminal-pane-mail-routing.ts b/src/main/runtime/orchestration/terminal-pane-mail-routing.ts index f42f9b9e87da..be74d152b6be 100644 --- a/src/main/runtime/orchestration/terminal-pane-mail-routing.ts +++ b/src/main/runtime/orchestration/terminal-pane-mail-routing.ts @@ -18,15 +18,14 @@ import type { StructuredPointerTarget } from './structured-mailbox-pointer-deliv import type { OrchestrationMailboxLeaf } from './mailbox-owner' import { chatDispatchMailTarget, - handleLessCoordinatorSessionId, + resolveStructuredCoordinatorMailboxTarget, structuredSessionAddressTarget, - structuredSessionMailTarget, structuredSessionIdleEdgeMailboxes } from './structured-session-mail-target' +import { captureStructuredWorkerSwitchStop } from './structured-worker-switch-stop' import { exitedChatDispatchesForSession } from './chat-assignee' import { OPERATOR_CLOSE_EXIT_CAUSE } from '../../../shared/terminal-exit-cause' import { resolveTerminalIdentityFromProbes } from '../terminal-identity-probe' - export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneMobileSessionTabGroupLayout { protected getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { const parsed = parsePaneKey(paneKey) @@ -208,6 +207,15 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW } } + captureSessionSwitchStop(sessionId: string) { + return captureStructuredWorkerSwitchStop( + sessionId, + this.getExistingOrchestrationDb(), + this.runtimeId, + this + ) + } + /** Settle ended chat Dispatches on tab hide; /clear successors retain their work. */ onStructuredSessionTabHidden(sessionId: string): void { this.removeWindowlessAgentSessionTab(sessionId) @@ -258,17 +266,7 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW protected resolveStructuredCoordinatorMailboxTarget( runId: string ): StructuredPointerTarget | null { - const run = this._orchestrationDb?.getRun?.(runId) - const sessionId = run ? handleLessCoordinatorSessionId(run) : null - if (sessionId) { - return structuredSessionMailTarget(sessionId, this._orchestrationDb) - } - const coordinator = run?.coordinator_handle - if (!coordinator) { - return null - } - const workerSessionId = this.liveStructuredWorkerSessionId(coordinator) - return workerSessionId ? { sessionId: workerSessionId, dispatchId: null } : null + return resolveStructuredCoordinatorMailboxTarget(this._orchestrationDb, runId) } /** Route direct worker mail through its active Dispatch or a session-scoped ledger between Dispatches; downstream idle/lease gates decide safety. */ diff --git a/src/main/runtime/profile-session-store.test-fixture.ts b/src/main/runtime/profile-session-store.test-fixture.ts index c04ec50b265a..30997c205eb3 100644 --- a/src/main/runtime/profile-session-store.test-fixture.ts +++ b/src/main/runtime/profile-session-store.test-fixture.ts @@ -1,4 +1,5 @@ // Model profile sessions by host, including routed layout commands and write notifications. +import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' @@ -10,14 +11,14 @@ import type { RuntimeStore } from './runtime-store-contract' export type SessionStore = { getWorkspaceSession?: (hostId?: string) => WorkspaceSessionState | null | undefined setWorkspaceSession?: (session: WorkspaceSessionState, hostId?: string) => void - getWorkspaceSessionHostIds?: () => readonly string[] + getWorkspaceSessionHostIds?: () => readonly ExecutionHostId[] applyLayoutCommand?: (command: LayoutCommand, hostIds?: readonly string[]) => Promise } & Pick type WriteListener = Parameters>[0] // The partitions a store without its own host list has had panes bound in. -const boundHostIdsByStore = new WeakMap>() +const boundHostIdsByStore = new WeakMap>() const installedSessionStores = new WeakSet() const installedLayoutApplies = new WeakSet() @@ -78,7 +79,7 @@ export function storeOf(runtime: OrcaRuntimeService): SessionStore | null { /** Supply missing profile session APIs, track host partitions, and retain state for fixture cleanup. */ export function asProfileSessionStore( store: SessionStore, - hostId: string + hostId: ExecutionHostId ): { get: (host: string) => WorkspaceSessionState set: (session: WorkspaceSessionState, host: string) => void @@ -94,7 +95,7 @@ export function asProfileSessionStore( } } if (!store.getWorkspaceSessionHostIds) { - const hostIds = new Set() + const hostIds = new Set() boundHostIdsByStore.set(store, hostIds) store.getWorkspaceSessionHostIds = () => [...hostIds] } @@ -105,19 +106,19 @@ export function asProfileSessionStore( const host = resolveHostId(hostIds[0]) const change = applyCommandToSession( host, - getWorkspaceSession(host) ?? getDefaultWorkspaceSession(), + getWorkspaceSession.call(store, host) ?? getDefaultWorkspaceSession(), command ) if (change.session) { - setWorkspaceSession(change.session, host) + setWorkspaceSession.call(store, change.session, host) } return change.value } } boundHostIdsByStore.get(store)?.add(hostId) return { - get: (host) => getWorkspaceSession(host) ?? getDefaultWorkspaceSession(), - set: (session, host) => setWorkspaceSession(session, host) + get: (host) => getWorkspaceSession.call(store, host) ?? getDefaultWorkspaceSession(), + set: (session, host) => setWorkspaceSession.call(store, session, host) } } diff --git a/src/main/runtime/published-pane-live-titles.ts b/src/main/runtime/published-pane-live-titles.ts index c6d15166cd98..7a0a9066895e 100644 --- a/src/main/runtime/published-pane-live-titles.ts +++ b/src/main/runtime/published-pane-live-titles.ts @@ -27,3 +27,13 @@ export function withLiveTitles( ) } } + +/** Collect titles in the same tab/pane order used by the ordinary live overlay. */ +export function paneLiveTitlesOf( + layout: PublishedWorkspaceLayout, + source: { liveTitle: (ptyId: string) => string | null } +): (string | null)[] { + return layout.tabs.flatMap((tab) => + (tab.terminal?.panes ?? []).map((pane) => (pane.ptyId ? source.liveTitle(pane.ptyId) : null)) + ) +} diff --git a/src/main/runtime/qoder-mobile-resume-adoption.test.ts b/src/main/runtime/qoder-mobile-resume-adoption.test.ts index 45afe0247c7d..bc5f0d37913a 100644 --- a/src/main/runtime/qoder-mobile-resume-adoption.test.ts +++ b/src/main/runtime/qoder-mobile-resume-adoption.test.ts @@ -1,5 +1,6 @@ import './rpc/unused-default-rpc-methods.test-fixture' import { expect, it, vi } from 'vitest' +import type { OwnedMobileTerminalCreateOptions } from './terminal/launch/mobile-terminal-create' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' @@ -14,6 +15,10 @@ const { bindPaneFirst, committingSpawn, registerBoundPty } = type SpawnPane = { worktreeId?: string; tabId?: string; leafId?: string } class QoderAdoptionRuntime extends OrcaRuntimeService { + createOwned(worktreeId: string, opts: OwnedMobileTerminalCreateOptions) { + return this.createRuntimeOwnedMobileSessionTerminal(worktreeId, false, undefined, opts) + } + retainedRecipe(ptyId: string, worktreeId: string) { return this.recordPtyWorktree(ptyId, worktreeId) } @@ -192,6 +197,7 @@ it.each([ ).rejects.toThrow('runtime_unavailable') inventoryUnavailable = false } + let retryError: unknown const retryPromise = runtime .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { ...resume, @@ -200,7 +206,10 @@ it.each([ : {}), signal: connection.signal }) - .catch(() => null) + .catch((error: unknown) => { + retryError = error + return null + }) await vi.advanceTimersByTimeAsync(10_001) const retry = await retryPromise expect(acceptedCommands).toHaveLength(1) @@ -209,7 +218,10 @@ it.each([ ? "qoder '--resume' 'same-qoder-session'" : "qoder '--captured-option' '--resume' 'same-qoder-session'" ) - expect(retry?.tab).toMatchObject({ ptyId: 'qoder-live-resume', launchAgent: 'qoder' }) + expect( + retry?.tab, + retryError instanceof Error ? retryError.message : undefined + ).toMatchObject({ ptyId: 'qoder-live-resume', launchAgent: 'qoder' }) expect(spawn).toHaveBeenCalledTimes(1) expect(send).not.toHaveBeenCalled() if (!retry) { diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-support.ts b/src/main/runtime/rpc/methods/agent-launch-caller-support.ts new file mode 100644 index 000000000000..34f87b7324f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-support.ts @@ -0,0 +1,13 @@ +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import type { RpcContext } from '../core' +export { callerRendersLaunchedChat } from './agent-launch-caller-selection' + +/** agent.launch.v2 declares support for both outcomes. CLI/runtime-socket and SSH bridges have no clientKind/capabilities; the desktop renderer is a runtime client and must advertise support. */ +export function supportsAgentLaunch( + context: Pick +): boolean { + return ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY) === true + ) +} diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 83bcb85418eb..5690349e75da 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -20,6 +20,7 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { markAgentLaunchesClosedByUser, + noteAgentLaunchPaneAccepted, resetAgentLaunchPanesForTests, resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' @@ -30,6 +31,10 @@ import type { RpcContext } from '../core' import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' import { STRUCTURED_PREFERENCE, + terminalOptions, + coldRecord, + failBeforeDispatch, + failAfterDispatch, methodNamed, rpcContext, runtimeStub, @@ -162,9 +167,9 @@ function hostWithWindow( (request: Omit) => runtime.publishAgentLaunchTab(request) ), - withdrawAgentLaunchPaneInMainLayout: vi.fn( - (_address: { worktreeId: string; tabId: string; leafId: string }) => true - ) + withdrawAgentLaunchPaneInMainLayout: vi.fn< + RpcContext['runtime']['withdrawAgentLaunchPaneInMainLayout'] + >(async (_address, current) => current?.() ?? false) }) } @@ -192,10 +197,6 @@ function plainLaunch( ) } -function terminalOptions(runtime: Host): Record { - return runtime.createTerminal.mock.calls[0]?.[1] ?? {} -} - /** What the pane's spawn decides when it mounts; `unowned` is an ordinary pane's spawn. */ async function paneVerdict( runtime: Host, @@ -208,28 +209,6 @@ async function paneVerdict( ) } -/** The record not open yet when the request arrives, as on the first launch after a restart. */ -function coldRecord(runtime: Host): Host { - runtime.openedAgentSessionRecordStore.mockReturnValueOnce(null) - return runtime -} - -/** A spawn that fails before the daemon was asked for a process: nothing can be running. */ -function failBeforeDispatch(runtime: Host, message: string): void { - runtime.createTerminal.mockRejectedValueOnce(new Error(message)) -} - -/** A spawn the daemon was asked for, whose answer was lost: the process may exist. */ -function failAfterDispatch(runtime: Host, message: string): void { - runtime.createTerminal.mockImplementationOnce(async (_selector, createOptions) => { - const dispatched = createOptions?.onPtySpawnDispatched - if (typeof dispatched === 'function') { - dispatched() - } - throw new Error(message) - }) -} - describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) => { beforeEach(() => { layoutOwner.isMain = owner === 'main' @@ -395,7 +374,12 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = }) await vi.waitFor(() => expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { + worktreeId: 'wt-7', + tabId: TAB_ID, + leafId: LEAF_ID, + operationId: runtime.published[0]?.operationId + }, { kind: 'not-started', code: 'spawn claude ENOENT' } ) ) @@ -511,7 +495,12 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = await vi.waitFor(() => expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { + worktreeId: 'wt-7', + tabId: TAB_ID, + leafId: LEAF_ID, + operationId: runtime.published[0]?.operationId + }, { kind: 'not-started', code: 'spawn claude ENOENT' } ) ) @@ -570,7 +559,7 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = expect(retried.published[0]?.operationId).toBe(operationId) await vi.waitFor(() => expect(retried.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID, operationId }, { kind: 'proceed' } ) ) @@ -588,7 +577,7 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = await replayLaunch(remadeForNothing, { paneKey: PANE_KEY, operationId }, CLI) await vi.waitFor(() => expect(remadeForNothing.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID, operationId }, { kind: 'withdrawn' } ) ) @@ -602,7 +591,7 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = await replayLaunch(survived, { paneKey: PANE_KEY, operationId }, CLI) await vi.waitFor(() => expect(survived.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID, operationId }, { kind: 'proceed' } ) ) @@ -890,11 +879,18 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = ) ) expect(refused.publishAgentLaunchTabInMainLayout).toHaveBeenCalledOnce() + const publication = await refused.publishAgentLaunchTabInMainLayout.mock.results[0]!.value + const [, current] = refused.withdrawAgentLaunchPaneInMainLayout.mock.calls[0]! await vi.waitFor(() => expect(refused.withdrawAgentLaunchPaneInMainLayout).toHaveBeenCalledWith( - expect.objectContaining({ worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }) + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID, operationId }, + current, + publication?.withdrawalRoot ) ) + expect(current?.()).toBe(true) + noteAgentLaunchPaneAccepted(PANE_KEY) + expect(current?.()).toBe(false) } ) @@ -913,8 +909,8 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = refused.publishAgentLaunchTabInMainLayout.mockImplementation((request) => real.runtime.publishAgentLaunchTabInMainLayout(request) ) - refused.withdrawAgentLaunchPaneInMainLayout.mockImplementation((address) => - real.runtime.withdrawAgentLaunchPaneInMainLayout(address) + refused.withdrawAgentLaunchPaneInMainLayout.mockImplementation((...args) => + real.runtime.withdrawAgentLaunchPaneInMainLayout(...args) ) const mainTabIds = () => real.session().tabsByWorktree[MAIN_WORKTREE_ID]?.map((tab) => tab.id) ?? [] @@ -941,7 +937,7 @@ describe.each(['window', 'main'] as const)('the %s owning the layout', (owner) = ).resolves.toMatchObject({ tabId: TAB_ID, created: true }) await vi.waitFor(() => expect(mainTabIds()).not.toContain(TAB_ID)) expect(refused.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: MAIN_WORKTREE_ID, tabId: TAB_ID, leafId: LEAF_ID }, + { worktreeId: MAIN_WORKTREE_ID, tabId: TAB_ID, leafId: LEAF_ID, operationId }, { kind: 'withdrawn' } ) } diff --git a/src/main/runtime/rpc/methods/agent-launch-surface-execution.ts b/src/main/runtime/rpc/methods/agent-launch-surface-execution.ts new file mode 100644 index 000000000000..4b70fec00579 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-surface-execution.ts @@ -0,0 +1,53 @@ +import type { AgentLaunchResult } from '../../../../shared/agent-launch-intent' +import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' +import type { RpcContext } from '../core' +import type { ReplaySafeLaunch } from './agent-launch-replay' +import { + agentLaunchCallerNavigationId, + selectAgentLaunchTabForCaller +} from './agent-launch-caller-selection' +import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' +import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import type { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' +import { withPlacement, type AgentLaunchView } from './agent-launch-tab-publication' + +export type HostLaunchContext = RpcContext & { + agentLaunchPolicy?: { onSurfacePublished?: (surface: AgentLaunchResult) => void } +} + +export async function runAgentLaunch( + { intent, decidedMode }: Awaited>, + context: HostLaunchContext, + view: AgentLaunchView, + replaySafe?: ReplaySafeLaunch +): Promise { + const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) + const result = await executeAgentLaunch({ + runtime: context.runtime, + intent, + decidedMode, + surfaces: agentLaunchSurfaceFactory( + context, + replaySafe?.attachOperationId, + replaySafe?.callerKey, + callerNavigationId !== null, + replaySafe?.terminalSpawn, + view + ), + workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + // The tab is shown as it is published, not after a prompt that can take a minute to land. + onSurfacePublished: (surface) => { + try { + context.agentLaunchPolicy?.onSurfacePublished?.(surface) + } catch (error) { + console.warn('[agent-launch] publication bookkeeping failed', error) + } + view.early?.surfacePublished(surface) + replaySafe?.recordSurface(withPlacement(surface, view)) + if (callerNavigationId !== null && view.presentation !== 'background') { + selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) + } + } + }) + return withPlacement(result, view) +} diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index 836b3578a2cb..c14f0df503bc 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -1,7 +1,3 @@ -/** Publish a stable-id tab before cold admission/spawn for replay-safe launches; its pane waits for the launch. - * Best-effort only: unresolved workspace, chat default, absent layout owner or occupied pane falls back to publication at spawn. - * After restart the pane behaves as an ordinary pane. */ - import { randomUUID } from 'node:crypto' import type { AgentLaunchPlacementReceipt, @@ -29,6 +25,10 @@ import type { AgentLaunchParams } from '../../../../shared/rpc-contract/agent-la import { agentLaunchOperationCallerKey } from './agent-launch-replay' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +/** Publish a stable-id tab before cold admission/spawn for replay-safe launches; its pane waits for the launch. + * Best-effort only: unresolved workspace, chat default, absent layout owner or occupied pane falls back to publication at spawn. + * After restart the pane behaves as an ordinary pane. */ + /** Paired launches move only the caller's view; both early publication and spawn must obey this. */ export function agentLaunchMovesHostWindow( context: Pick @@ -192,13 +192,28 @@ export async function publishAgentLaunchTabEarly( publishing, finishRunning: (verdict) => running.finish(verdict), agentBound: () => running.agentBound(), - paneIsLive: () => runtime.hasLiveTerminalForPaneKey(paneKey), + paneIsLive: () => !running.current() || runtime.hasLiveTerminalForPaneKey(paneKey), closedByUser: () => running.closedByUser(), - report: (verdict) => { - const address = { worktreeId: workspace.id, tabId: pane.tabId, leafId: pane.leafId } - // Main withdraws panes it owns; the window still receives the verdict to release retained launch state. - if (ORCA_LAYOUT_OWNER_IS_MAIN && verdict.kind === 'withdrawn') { - runtime.withdrawAgentLaunchPaneInMainLayout(address) + report: async (verdict) => { + const address = { + worktreeId: workspace.id, + tabId: pane.tabId, + leafId: pane.leafId, + operationId: params.operationId + } + if (!running.current()) { + return + } + if (verdict.kind === 'withdrawn') { + const published = await publishing + const withdrawn = await runtime.withdrawAgentLaunchPaneInMainLayout( + address, + running.current, + published.withdrawalRoot + ) + if (!withdrawn || !running.current()) { + return + } } runtime.reportAgentLaunchPaneVerdict(address, verdict) } @@ -212,7 +227,7 @@ function trackEarlyAgentLaunchTab(args: { agentBound: () => void paneIsLive: () => boolean closedByUser: () => boolean - report: (verdict: AgentLaunchPaneVerdict) => void + report: (verdict: AgentLaunchPaneVerdict) => Promise }): EarlyAgentLaunchTab { let reply: AgentLaunchTabPublished | null = null let publicationFailed = false @@ -264,8 +279,8 @@ function trackEarlyAgentLaunchTab(args: { : failureCode === null ? { kind: 'unconfirmed' } : { kind: 'not-started', code: failureCode } + void args.report(verdict).catch(() => {}) args.finishRunning(verdict) - args.report(verdict) }) }, placement: () => reply?.placement diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index f85a8a8e8a01..96da46a66851 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -1,3 +1,4 @@ +import { noteAgentLaunchPaneAccepted } from '../../../agent-launch/agent-launch-pane-attachment' /** Use one runtime stub for RPC and replay tests so they exercise the same host surface. */ import { vi } from 'vitest' @@ -165,6 +166,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { (request: Omit) => options.publishAgentLaunchTab?.(request) ?? null ), + noteAcceptedAgentLaunchPane: vi.fn((paneKey: string) => noteAgentLaunchPaneAccepted(paneKey)), reportAgentLaunchPaneVerdict: vi.fn( (_pane: { worktreeId: string; tabId: string; leafId: string }, _verdict: unknown) => {} ), @@ -204,3 +206,29 @@ export const CAPABLE_CLIENT: Partial = { pairedDeviceId: 'device-1', clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } + +export function terminalOptions(runtime: AgentLaunchRuntimeStub): Record { + return runtime.createTerminal.mock.calls[0]?.[1] ?? {} +} + +/** The record not open yet when the request arrives, as on the first launch after a restart. */ +export function coldRecord(runtime: T): T { + runtime.openedAgentSessionRecordStore.mockReturnValueOnce(null) + return runtime +} + +/** A spawn that fails before the daemon was asked for a process: nothing can be running. */ +export function failBeforeDispatch(runtime: AgentLaunchRuntimeStub, message: string): void { + runtime.createTerminal.mockRejectedValueOnce(new Error(message)) +} + +/** A spawn the daemon was asked for, whose answer was lost: the process may exist. */ +export function failAfterDispatch(runtime: AgentLaunchRuntimeStub, message: string): void { + runtime.createTerminal.mockImplementationOnce(async (_selector, createOptions) => { + const dispatched = createOptions?.onPtySpawnDispatched + if (typeof dispatched === 'function') { + dispatched() + } + throw new Error(message) + }) +} diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index ecc26fd36f08..a736b41ce10c 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -11,15 +11,10 @@ import { WorktreeCreateCollisionError, WORKTREE_CREATE_COLLISION_CODE } from '../../../../shared/new-workspace/worktree-create-collision' -import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' import { trackTerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcContext } from '../core' -import { - admitAgentLaunchOperation, - agentLaunchOperationCallerKey, - type ReplaySafeLaunch -} from './agent-launch-replay' +import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay' import { AgentLaunchExecutionError, agentLaunchRefusalError, @@ -33,71 +28,21 @@ import { AgentLaunchReplay, type AgentLaunchParams } from '../../../../shared/rpc-contract/agent-launch-params' -import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' import { agentLaunchFailureCode, launchFailureWithoutEffectsCode } from './agent-launch-failure-code' -import { - agentLaunchCallerNavigationId, - callerRendersLaunchedChat, - selectAgentLaunchTabForCaller -} from './agent-launch-caller-selection' -import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { callerRendersLaunchedChat } from './agent-launch-caller-selection' import { assertAgentLaunchTargetAuthorized, supportsAgentLaunch } from './agent-launch-target-authorization' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' -import { - publishEarlyTab, - withPlacement, - type AgentLaunchView -} from './agent-launch-tab-publication' +import { publishEarlyTab, type AgentLaunchView } from './agent-launch-tab-publication' +import { runAgentLaunch, type HostLaunchContext } from './agent-launch-surface-execution' export { supportsAgentLaunch } from './agent-launch-target-authorization' -type HostLaunchContext = RpcContext & { - agentLaunchPolicy?: { onSurfacePublished?: (surface: AgentLaunchResult) => void } -} - -async function runAgentLaunch( - { intent, decidedMode }: Awaited>, - context: HostLaunchContext, - view: AgentLaunchView, - replaySafe?: ReplaySafeLaunch -): Promise { - const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) - const result = await executeAgentLaunch({ - runtime: context.runtime, - intent, - decidedMode, - surfaces: agentLaunchSurfaceFactory( - context, - replaySafe?.attachOperationId, - replaySafe?.callerKey, - callerNavigationId !== null, - replaySafe?.terminalSpawn, - view - ), - workspaces: agentLaunchWorkspaceFactory(context, intent.agent), - // The tab is shown as it is published, not after a prompt that can take a minute to land. - onSurfacePublished: (surface) => { - try { - context.agentLaunchPolicy?.onSurfacePublished?.(surface) - } catch (error) { - console.warn('[agent-launch] publication bookkeeping failed', error) - } - view.early?.surfacePublished(surface) - replaySafe?.recordSurface(withPlacement(surface, view)) - if (callerNavigationId !== null && view.presentation !== 'background') { - selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) - } - } - }) - return withPlacement(result, view) -} - /** Legacy unnamed launches retain the 60-second create dedupe cache (repo/mutation id, no caller partition). * Named operations bypass it: durable admission alone decides execution, including existing-workspace launches. */ function runLegacyAgentLaunch( @@ -248,6 +193,9 @@ function runReplaySafeAgentLaunch( return active.promise } + if (params.paneKey) { + context.runtime.noteAcceptedAgentLaunchPane(params.paneKey, params.operationId) + } let promise: Promise promise = executeReplaySafeAgentLaunch(params, context, fingerprint).finally(() => { if (activeAgentLaunches.get(key)?.promise === promise) { @@ -312,6 +260,9 @@ export async function launchAgentOnHost( } assertAgentLaunchTargetAuthorized(params.target, host) if (!operationId) { + if (params.paneKey) { + host.runtime.noteAcceptedAgentLaunchPane(params.paneKey, operationId) + } return runLegacyAgentLaunch(params, host) } return runReplaySafeAgentLaunch({ ...params, operationId }, host).catch((error: unknown) => { diff --git a/src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts b/src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts index 232bd9139066..89927a88313c 100644 --- a/src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts @@ -24,7 +24,7 @@ describe('orchestration CLI/runtime boundary', () => { let db: OrchestrationDb let ctx: RpcContext - afterEach(() => { + afterEach(async () => { h.cleanup() restoreTerminalHandle() vi.doUnmock('../../../../../cli/format') diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 0372b9125e72..b70416f16b0f 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -84,6 +84,7 @@ export const SESSION_TAB_METHODS = [ ? { resumeProviderSession: params.resumeProviderSession } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, select: params.select, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-options.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-options.ts new file mode 100644 index 000000000000..e0b205a73761 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-options.ts @@ -0,0 +1,33 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../../shared/agent-session-wire' +import type { + StructuredAgentSessionResumeSource, + StructuredAgentSessionFirstMessage +} from '../../../../shared/structured-agent-session-create' + +export function structuredAgentSessionCreateIntentFingerprint(params: { + envelope: AgentSessionMutationEnvelope + worktree: string + agent: string + resumeFrom?: StructuredAgentSessionResumeSource + tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + optionsMode?: 'replace-model-selection' + options?: Readonly> +}): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { + worktree: params.worktree, + agent: params.agent, + resumeFrom: params.resumeFrom, + tabId: params.tabId, + firstMessage: params.firstMessage, + optionsMode: params.optionsMode, + options: params.options + } + }) +} + +export { seededPermissionMode } from '../../../../shared/native-chat/input/native-chat-session-option-defaults' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 21dd8680ce76..ceac52b48174 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,8 +1,6 @@ import { seededPermissionMode } from '../../../../shared/native-chat/input/native-chat-session-option-defaults' -// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path. - -import { assertAgentPromptRequestActive } from '../../orca-runtime-core' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' +import { assertAgentPromptRequestActive } from '../../orca-runtime-core' import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { @@ -32,6 +30,9 @@ import { resolveUncommittedStructuredCreate } from './structured-agent-session-p import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create' import type { ChatTabPlacement } from '../../agent-session/agent-session-tab-layout' +import { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create-options' + +// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path. export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost @@ -44,32 +45,6 @@ export type PreparedStructuredAgentSessionCreate = { atRest?: { firstMessage?: StructuredAgentSessionFirstMessage } } -/** New creates keep the immutable client intent in the ledger; legacy creates keep attach identity. */ -export function structuredAgentSessionCreateIntentFingerprint(params: { - envelope: AgentSessionMutationEnvelope - worktree: string - agent: string - resumeFrom?: StructuredAgentSessionResumeSource - tabId?: string - firstMessage?: StructuredAgentSessionFirstMessage - optionsMode?: 'replace-model-selection' - options?: Readonly> -}): string { - return computeAgentSessionPayloadFingerprint({ - method: 'agentSession.create', - sessionId: params.envelope.sessionId, - fields: { - worktree: params.worktree, - agent: params.agent, - resumeFrom: params.resumeFrom, - tabId: params.tabId, - firstMessage: params.firstMessage, - optionsMode: params.optionsMode, - options: params.options - } - }) -} - /** Run throwing pre-commit work inside resolveUncommittedStructuredCreate so clients receive a no-effects refusal. */ export async function prepareStructuredAgentSessionCreateForWorktree(args: { runtime: OrcaRuntimeService @@ -173,6 +148,7 @@ export async function commitStructuredAgentSessionCreate(args: { caller: StructuredAgentSessionCaller prepared: PreparedStructuredAgentSessionCreate activate: boolean + check?: () => void /** This machine's window made the create and opens the chat's tab where it reserved it. */ placedByWindow?: boolean /** Where the creating client opens the chat, in this host's ids. */ @@ -187,6 +163,7 @@ export async function commitStructuredAgentSessionCreate(args: { if (prepared.atRest) { return prepared.host.create(args.caller, prepared.attachParams, { ...prepared.atRest, + check: args.check, ...(prepared.catalogModels ? { catalogModels: prepared.catalogModels } : {}), ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } @@ -203,6 +180,7 @@ export async function commitStructuredAgentSessionCreate(args: { sessionId: prepared.attachParams.envelope.sessionId, agent: tab.agent, activate: args.activate, + ...(args.check ? { check: args.check } : {}), ...tabPlace, ...(prepared.attachParams.surfaceTabId ? { tabId: prepared.attachParams.surfaceTabId } @@ -293,3 +271,5 @@ export async function createStructuredAgentSessionForWorktree(args: { activate: args.activate }) } + +export { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create-options' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index a30d4ec03e0e..512271f30349 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -103,6 +103,8 @@ export const WORK_METHODS = [ { method: 'agentSession.subscribe', params: { sessionId: SESSION } }, { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, { method: 'agentSession.reveal', params: { sessionId: SESSION } }, + { method: 'agentSession.switchToTerminal', params: { sessionId: SESSION } }, + { method: 'agentSession.switchToChat', params: { sessionId: SESSION } }, { method: 'agentSession.regenerateName', params: { sessionId: SESSION } }, { method: 'agentSession.rename', params: { sessionId: SESSION, name: 'Release notes' } }, { method: 'agentSession.subscribeStatus', params: null } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 28d467c7ed5c..ab33241f2058 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -17,6 +17,8 @@ export { ModelCatalogParams, MutationEnvelope, OptionsParams, + SwitchToChatParams, + SwitchToTerminalParams, QueuedMessageActionParams, QueuedMessagesResumeParams, RespondParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-sleep-workspace.ts b/src/main/runtime/rpc/methods/structured-agent-session-sleep-workspace.ts new file mode 100644 index 000000000000..760ee2f64a56 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-sleep-workspace.ts @@ -0,0 +1,60 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { defineMethod } from '../core' +import { + requireInstalledStructuredHost as requireInstalledHost, + requireStructuredCapability +} from './structured-agent-session-gate' +import { SleepWorkspaceParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_SLEEP_WORKSPACE_METHODS = [ + defineMethod({ + name: 'agentSession.sleepWorkspace', + permission: 'workspace', + params: SleepWorkspaceParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + if (params.markAsleep) { + return (await requireInstalledHost(ctx)).sleepWorkspace(params.workspaceId, true) + } + const installed = getStructuredAgentSessionHost() + if (installed) { + return installed.sleepWorkspace(params.workspaceId, params.markAsleep) + } + let store: Awaited> + try { + store = await ctx.runtime.openAgentSessionRecordStore() + } catch (error) { + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) + } + console.warn('[agent-session] workspace sleep record inventory unavailable', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) + } + try { + const records = store + .listConversations() + .filter( + (record) => + record.location.workspaceId === params.workspaceId && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + const stoppedSessionIds = records + .filter((record) => record.neverStarted) + .map((record) => record.sessionId) + const failures = records + .filter((record) => !record.neverStarted) + .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } + } catch (error) { + console.warn('[agent-session] workspace sleep record inventory failed', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.test.ts new file mode 100644 index 000000000000..ac0eb555c7bc --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.test.ts @@ -0,0 +1,775 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import * as cliTranscript from '../../../ai-vault/structured-chat-cli-fork' +import { structuredAgentRuntimeMetadata } from '../../structured-agent-runtime-metadata' +import * as mobileProjection from '../../runtime-mobile-session-projection' +import { agentSessionProviderHandleChainHead } from '../../../../shared/agent-session-provider-handle' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + createTestParams, + CREATE_TEST_CALLER as caller +} from '../../../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { HOST_TEST_SESSION as id } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + setupMainOwnedWorkspace, + WORKTREE_ID, + TAB_ID, + LEAF_A, + PTY_A, + PTY_B, + LEAF_B +} from '../../main-owned-workspace-runtime-fixture' +import { executeStructuredSessionSwitch } from './structured-agent-session-switch-execution' +import { DaemonRequestTimeoutError } from '../../../daemon/daemon-errors' +import type { RuntimeMobileSessionTerminalClientTab } from '../../../../shared/runtime-mobile-session-tab-contracts' + +let rig: RestTestRig +let releaseReceiverExit: (() => void) | undefined +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + const params = createTestParams() + expect( + await rig.host.create( + caller, + createTestParams(undefined, { + location: { ...params.location, workspaceId: WORKTREE_ID } + }) + ) + ).toMatchObject({ ok: true }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + releaseReceiverExit?.() + releaseReceiverExit = undefined + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.restoreAllMocks() +}) +async function savedTranscript(sessionId = id) { + await rig.host.switchToChat(sessionId) + await rig.host.switchToTerminal(sessionId) + const record = rig.store.getRecord(sessionId)! + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)! + const codexHome = join(rig.root, 'codex-account') + const sessions = join(codexHome, 'sessions') + await mkdir(sessions, { recursive: true }) + const filePath = join(sessions, 'switch-source.jsonl') + const nativeId = head.handle.nativeId + await writeFile( + filePath, + `${[ + { type: 'session_meta', payload: { id: nativeId } }, + { + timestamp: '2026-10-26T12:00:00Z', + type: 'event_msg', + payload: { type: 'task_started', turn_id: 'cli-turn' } + }, + { + type: 'event_msg', + payload: { + type: 'user_message', + message: 'A terminal prompt' + } + }, + { + timestamp: '2026-10-26T12:00:01Z', + type: 'event_msg', + payload: { type: 'task_complete', turn_id: 'cli-turn' } + } + ] + .map((row) => JSON.stringify(row)) + .join('\n')}\n` + ) + vi.spyOn(cliTranscript, 'resolveStructuredChatCliSource').mockResolvedValue({ + host: rig.host, + record, + head, + agent: 'codex', + cwd: rig.root, + providerSessionId: nativeId, + requireCurrent: vi.fn(), + metadata: structuredAgentRuntimeMetadata('codex')! + }) + vi.spyOn(cliTranscript, 'prepareStructuredChatCliTranscript').mockResolvedValue({ + useRealCodexHome: false, + cliForkSource: { + agent: 'codex', + sourceAgent: 'codex', + sessionId: nativeId, + filePath, + cwd: rig.root, + codexHome, + executionHostPlatform: process.platform + } + }) +} + +it.each(['single', 'split', 'other-workspace'] as const)( + 'uses ordinary exact physical exit and source placement, retaining siblings: %s', + async (shape) => { + const split = shape === 'split' + const elsewhere = shape === 'other-workspace' + const sessionId = elsewhere ? 'existing-native-elsewhere' : id + const destinationWorkspace = elsewhere ? 'repo-1::/other-workspace' : WORKTREE_ID + if (elsewhere) { + const acquire = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementation(async (input) => { + const resumed = Boolean( + rig.store.getRecord(input.identity.sessionId)?.providerHandleChain.length + ) + const receipt = await acquire(input) + if (!receipt.link) { + throw new Error('missing fixture provider identity') + } + return { + ...receipt, + link: { ...receipt.link, origin: resumed ? 'resumed' : 'created' } + } + }) + const params = createTestParams() + expect( + await rig.host.create( + caller, + createTestParams(undefined, { + envelope: { ...params.envelope, sessionId }, + location: { ...params.location, workspaceId: destinationWorkspace } + }) + ) + ).toMatchObject({ ok: true }) + } + await savedTranscript(sessionId) + const { runtime, session } = setupMainOwnedWorkspace({ split: shape === 'split' }) + const incarnationId = '11111111-1111-4111-8111-111111111111' + runtime.registerPty(PTY_A, WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_A, incarnationId }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_A}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('missing captured source') + } + const exit = Promise.withResolvers() + const entered = Promise.withResolvers() + const stopped = vi.fn(async (ptyId: string) => { + expect(ptyId).toBe(PTY_A) + entered.resolve() + await exit.promise + await runtime.onPtyExit(PTY_A, 0, incarnationId, { hostExitConfirmed: true }) + return true + }) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: stopped, + getForegroundProcess: async () => 'codex' + }) + expect( + await runtime.applyLayoutCommand({ + type: 'openAgentSessionTab', + workspace: WORKTREE_ID, + sessionId: 'later-tab', + agent: 'codex', + tabId: 'agent-session:later-tab' + }) + ).toMatchObject({ ok: true }) + if (elsewhere) { + await runtime.publishStructuredAgentSessionTab({ + workspaceId: destinationWorkspace, + sessionId, + agent: 'codex', + activate: false + }) + } + const existingDestination = elsewhere + ? await vi.waitFor(() => { + const tabs = + session().unifiedTabs?.[destinationWorkspace]?.filter( + (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId + ) ?? [] + expect(tabs).toHaveLength(1) + const tab = tabs[0]! + const group = session().tabGroups?.[destinationWorkspace]?.find((row) => + row.tabOrder.includes(tab.id) + ) + expect(group?.tabOrder).toEqual([tab.id]) + return { tabId: tab.id, groupId: group!.id, tabOrder: [...group!.tabOrder] } + }) + : undefined + const originalSourceTabs = structuredClone(session().tabsByWorktree[WORKTREE_ID]) + const originalSourceLayout = structuredClone(session().terminalLayoutsByTabId[TAB_ID]) + const acquisitions = rig.adapter.acquire.mock.calls.length + const operation = executeStructuredSessionSwitch( + { + sessionId, + execution: { + invocationId: 'from-pane', + source: { + terminal: source.handle, + expectedIncarnationId: incarnationId, + originalPresentation: source.presentation + } + } + }, + { runtime }, + 'chat' + ) + await Promise.race([ + entered.promise, + operation.then((answer) => { + throw new Error(JSON.stringify(answer)) + }) + ]) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(acquisitions) + exit.resolve() + expect(await operation).toEqual({ status: 'completed', sessionId }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(acquisitions + 1)) + expect(stopped).toHaveBeenCalledOnce() + const destination = `agent-session:${sessionId}` + const savedDestination = existingDestination?.tabId ?? destination + const group = session().tabGroups?.[destinationWorkspace]?.find((row) => + existingDestination + ? row.id === existingDestination.groupId + : row.tabOrder.includes(destination) + ) + expect(group?.tabOrder).toEqual( + existingDestination?.tabOrder ?? [ + ...(split ? [TAB_ID] : []), + destination, + ...(elsewhere ? [] : ['agent-session:later-tab']) + ] + ) + expect(group?.activeTabId).toBe(savedDestination) + if (existingDestination) { + expect( + session().unifiedTabs?.[destinationWorkspace]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId + )?.id + ).toBe(existingDestination.tabId) + } + const published = await runtime.listMobileSessionTabs(`id:${destinationWorkspace}`) + expect(published.activeTabId).toBe(destination) + expect(published.tabs.find((tab) => tab.id === destination)).toMatchObject({ + openPlacement: { + invocationId: 'from-pane', + generation: expect.any(String), + placement: elsewhere ? {} : { afterTabId: TAB_ID, index: 0 }, + ...(elsewhere ? {} : { source: source.presentation.identity }) + } + }) + if (elsewhere) { + const destinationTab = published.tabs.find( + (tab) => tab.type === 'agent-session' && tab.id === destination + ) + expect( + destinationTab?.type === 'agent-session' && destinationTab.openPlacement?.source + ).toBeUndefined() + expect(session().tabsByWorktree[WORKTREE_ID]).toEqual(originalSourceTabs) + expect(session().terminalLayoutsByTabId[TAB_ID]).toEqual(originalSourceLayout) + expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'terminal', parentTabId: TAB_ID })]) + ) + expect(rig.store.getRecord(sessionId)?.location.workspaceId).toBe(destinationWorkspace) + } + const layout = session().terminalLayoutsByTabId[TAB_ID] + if (split) { + expect(layout?.ptyIdsByLeafId?.[LEAF_B]).toBe(PTY_B) + expect(layout?.root).toEqual({ type: 'leaf', leafId: LEAF_B }) + } else if (!elsewhere) { + expect(session().tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === TAB_ID)).toBe(false) + } + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + } +) + +it('keeps the stopped source when destination publication fails before retirement', async () => { + await savedTranscript() + const { runtime, session } = setupMainOwnedWorkspace() + const incarnationId = '11111111-1111-4111-8111-111111111111' + runtime.registerPty(PTY_A, WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_A, incarnationId }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_A}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('missing captured source') + } + const originalSourceTabs = structuredClone(session().tabsByWorktree[WORKTREE_ID]) + const originalSourceLayout = structuredClone(session().terminalLayoutsByTabId[TAB_ID]) + const acquisitions = rig.adapter.acquire.mock.calls.length + const stop = vi.fn(async () => { + await runtime.onPtyExit(PTY_A, 0, incarnationId, { hostExitConfirmed: true }) + return true + }) + const kill = vi.fn(() => false) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: stop, + getForegroundProcess: async () => null + }) + const publish = vi + .spyOn(runtime, 'publishStructuredAgentSessionTab') + .mockRejectedValue(new Error('fixture publication failure')) + const result = await executeStructuredSessionSwitch( + { + sessionId: id, + execution: { + invocationId: 'publication-failure', + source: { + terminal: source.handle, + expectedIncarnationId: incarnationId, + originalPresentation: source.presentation + } + } + }, + { runtime }, + 'chat' + ) + expect(result).toMatchObject({ + status: 'refused', + refusal: { code: 'agent_session_owner_restart_failed' } + }) + expect(stop).toHaveBeenCalledExactlyOnceWith( + PTY_A, + expect.objectContaining({ expectedIncarnationId: incarnationId }) + ) + expect(runtime.getPtyLivenessVerdict(PTY_A)).toMatchObject({ status: 'exited' }) + expect(publish).toHaveBeenCalledOnce() + expect(session().tabsByWorktree[WORKTREE_ID]).toEqual(originalSourceTabs) + expect(session().terminalLayoutsByTabId[TAB_ID]).toEqual(originalSourceLayout) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(acquisitions) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() +}) + +it('does not acquire or retire a terminal whose physical stop is unproven', async () => { + await savedTranscript() + const { runtime, session } = setupMainOwnedWorkspace() + const incarnationId = '11111111-1111-4111-8111-111111111111' + runtime.registerPty(PTY_A, WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_A, incarnationId }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_A}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('missing captured source') + } + const acquisitions = rig.adapter.acquire.mock.calls.length + const stop = vi.fn(async () => false) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: stop, + getForegroundProcess: async () => null + }) + const result = await executeStructuredSessionSwitch( + { + sessionId: id, + execution: { + invocationId: 'unproven', + source: { + terminal: source.handle, + expectedIncarnationId: incarnationId, + originalPresentation: source.presentation + } + } + }, + { runtime }, + 'chat' + ) + expect(result.status).toBe('refused') + expect(stop).toHaveBeenCalledOnce() + expect(rig.adapter.acquire).toHaveBeenCalledTimes(acquisitions) + expect(session().tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === TAB_ID)).toBe(true) +}) + +it('reports a dispatched terminal with a lost answer as uncertain, preserving its source', async () => { + const { runtime } = setupMainOwnedWorkspace() + await rig.store.setSessionTabVisibility(id, false) + await rig.store.setSessionTabVisibility(id, true, 'source-chat') + vi.spyOn(runtime, 'createSessionSwitchTerminal').mockRejectedValue( + new DaemonRequestTimeoutError('raw daemon text must not become start-failed copy') + ) + const result = await executeStructuredSessionSwitch( + { sessionId: id, execution: { invocationId: 'lost', sourceTabId: 'source-chat' } }, + { runtime }, + 'terminal' + ) + expect(result).toMatchObject({ + status: 'uncertain', + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + expect(rig.store.getSessionTabId(id)).toBe('source-chat') +}) + +it.each([ + { navigate: false, viewerSelection: undefined }, + { navigate: true, viewerSelection: undefined }, + { navigate: false, viewerSelection: 'selection-v1:initiating-viewer' } +])( + 'captures selection before native Stop and consumes it at the ordinary terminal snapshot: $navigate/$viewerSelection', + async ({ navigate, viewerSelection }) => { + const { runtime, bindPty, handleOf, session } = setupMainOwnedWorkspace() + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: WORKTREE_ID, + path: rig.root, + connectionId: null, + repo: null, + folderWorkspace: null + }) + }) + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WORKTREE_ID, + sessionId: id, + agent: 'codex', + activate: true, + check: () => {} + }) + const other = createTestParams() + expect( + await rig.host.create(caller, { + ...other, + envelope: { ...other.envelope, sessionId: 'newer-navigation' }, + location: { ...other.location, workspaceId: WORKTREE_ID } + }) + ).toMatchObject({ ok: true }) + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WORKTREE_ID, + sessionId: 'newer-navigation', + agent: 'codex', + activate: false + }) + await rig.host.switchToChat(id) + const nativeIdentity = agentSessionProviderHandleChainHead( + rig.store.getRecord(id)!.providerHandleChain + )!.handle + const stopping = Promise.withResolvers() + const stopped = Promise.withResolvers() + const close = rig.adapter.closeSession.getMockImplementation()! + rig.adapter.closeSession.mockImplementation(async (...args) => { + stopping.resolve() + await stopped.promise + return close(...args) + }) + const receiverExit = Promise.withResolvers() + releaseReceiverExit = () => receiverExit.resolve() + let ordinaryCreates = 0 + const stopForSwitch = vi.spyOn(rig.host, 'stopForSwitch') + const spawn = vi + .spyOn(runtime, 'createTerminal') + .mockImplementation(async (_selector, opts = {}) => { + ordinaryCreates += 1 + if (!opts.tabId || !opts.leafId) { + throw new Error('missing ordinary published pane') + } + if (!navigate && !viewerSelection && ordinaryCreates === 1) { + void rig.host.trackSwitchPhysical(id, receiverExit.promise) + } + opts.beforePtySpawn?.() + opts.onPtySpawnDispatched?.({ + tabId: opts.tabId, + leafId: opts.leafId, + launchAgent: 'codex', + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}) + }) + const ptyId = ordinaryCreates === 1 ? 'switch-destination' : 'switch-destination-next' + bindPty({ + worktreeId: WORKTREE_ID, + tabId: opts.tabId, + leafId: opts.leafId, + ptyId + }) + runtime.registerPty(ptyId, WORKTREE_ID, null, { + tabId: opts.tabId, + leafId: opts.leafId, + terminalHandle: opts.preAllocatedHandle + }) + return { + handle: handleOf(ptyId), + ptyId, + tabId: opts.tabId, + paneKey: `${opts.tabId}:${opts.leafId}`, + worktreeId: WORKTREE_ID, + title: null, + surface: 'background' + } + }) + const operation = executeStructuredSessionSwitch( + { + sessionId: id, + execution: { + invocationId: 'ordinary-native-stop', + sourceTabId: `agent-session:${id}`, + ...(viewerSelection ? { viewerSelection } : {}) + } + }, + { runtime }, + 'terminal' + ) + await Promise.race([ + stopping.promise, + operation.then((answer) => { + throw new Error(JSON.stringify(answer)) + }) + ]) + expect(spawn).not.toHaveBeenCalled() + runtime.projectStructuredAgentSessionTab({ + workspaceId: 'another-workspace', + sessionId: 'automatic-publication', + agent: 'codex', + activate: true + }) + if (navigate) { + const picked = await runtime.activateMobileSessionTab( + `id:${WORKTREE_ID}`, + 'agent-session:newer-navigation', + undefined, + { navigation: 'all', intent: 'user', clientNavigationId: 'host-follow-viewer' } + ) + expect(picked.activeTabId).toBe('agent-session:newer-navigation') + } + const outstanding = + !navigate && !viewerSelection + ? executeStructuredSessionSwitch( + { + sessionId: id, + execution: { + invocationId: 'outstanding-duplicate', + sourceTabId: `agent-session:${id}` + } + }, + { runtime }, + 'terminal' + ) + : undefined + const replay = + !navigate && !viewerSelection + ? executeStructuredSessionSwitch( + { + sessionId: id, + execution: { + invocationId: 'ordinary-native-stop', + sourceTabId: `agent-session:${id}` + } + }, + { runtime }, + 'terminal' + ) + : undefined + stopped.resolve() + const answer = await operation + if (outstanding) { + expect(await outstanding).toEqual(answer) + } + if (replay) { + expect(await replay).toEqual(answer) + } + expect(answer).toMatchObject({ status: 'completed' }) + expect(spawn).toHaveBeenCalledOnce() + expect(spawn.mock.calls[0]![1]!.presentation).toBe('background') + expect(spawn.mock.calls[0]![1]!.agentLaunchViewer?.()).toBe('none') + const snapshot = await runtime.listMobileSessionTabs( + `id:${WORKTREE_ID}`, + navigate ? 'host-follow-viewer' : undefined + ) + if (viewerSelection) { + expect( + snapshot.tabs.find( + (tab): tab is RuntimeMobileSessionTerminalClientTab => + tab.type === 'terminal' && tab.ptyId === 'switch-destination' + )?.openPlacement + ).toMatchObject({ + activate: true, + viewerSelection + }) + } + expect(snapshot.activeTabId).toBe( + navigate + ? 'agent-session:newer-navigation' + : viewerSelection + ? `agent-session:${id}` + : `${spawn.mock.calls[0]![1]!.tabId}::${spawn.mock.calls[0]![1]!.leafId}` + ) + if (!navigate && !viewerSelection) { + const request = (invocationId: string) => + executeStructuredSessionSwitch( + { + sessionId: id, + execution: { invocationId, sourceTabId: `agent-session:${id}` } + }, + { runtime }, + 'terminal' + ) + if (answer.status !== 'completed' || !answer.terminal) { + receiverExit.resolve() + throw new Error('missing completed destination') + } + const picked = await runtime.activateMobileSessionTab( + `id:${WORKTREE_ID}`, + 'agent-session:newer-navigation', + undefined, + { navigation: 'all', intent: 'user', clientNavigationId: 'host-follow-viewer' } + ) + expect(picked.activeTabId).toBe('agent-session:newer-navigation') + const projection = vi.spyOn(mobileProjection, 'projectRuntimeMobileSessionTabs') + expect(await request('ordinary-native-stop')).toEqual(answer) + expect(await request('already-published-physical-join')).toEqual(answer) + expect(projection).not.toHaveBeenCalled() + expect( + (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`, 'host-follow-viewer')).activeTabId + ).toBe('agent-session:newer-navigation') + expect(spawn).toHaveBeenCalledOnce() + expect(stopForSwitch).toHaveBeenCalledOnce() + try { + expect(rig.host.switchExecuting(id)).toBe(true) + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, answer.terminal, { + reason: 'user' + }) + expect(runtime.currentSessionSwitchDestination(WORKTREE_ID, answer)).toBe(false) + expect(await request('missing-but-physically-unsettled')).toEqual(answer) + expect(spawn).toHaveBeenCalledOnce() + expect(stopForSwitch).toHaveBeenCalledOnce() + } finally { + receiverExit.resolve() + } + await vi.waitFor(() => expect(rig.host.switchExecuting(id)).toBe(false)) + const unknown = vi + .spyOn(runtime, 'currentSessionSwitchDestination') + .mockReturnValue(undefined) + expect(await request('unknown-destination')).toMatchObject({ + status: 'refused', + refusal: { code: 'agent_session_ownership_unknown' } + }) + expect(spawn).toHaveBeenCalledOnce() + expect(await request('ordinary-native-stop')).toEqual(answer) + expect(unknown).toHaveBeenCalledOnce() + unknown.mockRestore() + expect(spawn).toHaveBeenCalledOnce() + expect(stopForSwitch).toHaveBeenCalledOnce() + const replacement = await request('fresh-history-after-close') + expect(replacement).toMatchObject({ status: 'completed', sessionId: id }) + expect(replacement).not.toMatchObject({ terminal: answer.terminal }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(runtime.currentSessionSwitchDestination(WORKTREE_ID, replacement)).toBe(true) + await vi.waitFor(() => expect(rig.host.switchExecuting(id)).toBe(false)) + await runtime.activateMobileSessionTab( + `id:${WORKTREE_ID}`, + 'agent-session:newer-navigation', + undefined, + { navigation: 'all', intent: 'user', clientNavigationId: 'host-follow-viewer' } + ) + expect(await request('surviving-destination')).toEqual(replacement) + expect( + (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`, 'host-follow-viewer')).activeTabId + ).toBe('agent-session:newer-navigation') + expect(spawn).toHaveBeenCalledTimes(2) + expect(stopForSwitch).toHaveBeenCalledTimes(2) + expect( + agentSessionProviderHandleChainHead(rig.store.getRecord(id)!.providerHandleChain)!.handle + ).toEqual(nativeIdentity) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + if (replacement.status !== 'completed' || !replacement.terminal) { + throw new Error('missing completed replacement') + } + const destinationId = replacement.terminal + for (const size of [10, 100]) { + for ( + let index = runtime.mobileSessionTabsByWorktree.get(WORKTREE_ID)!.tabs.length; + index < size; + index += 1 + ) { + const sessionId = `inventory-${index}` + expect( + await runtime.applyLayoutCommand({ + type: 'openAgentSessionTab', + workspace: WORKTREE_ID, + sessionId, + agent: 'codex', + tabId: `agent-session:${sessionId}` + }) + ).toMatchObject({ ok: true }) + runtime.projectStructuredAgentSessionTab({ + workspaceId: WORKTREE_ID, + sessionId, + agent: 'codex', + activate: false + }) + runtime.projectStructuredAgentSessionTab({ + workspaceId: 'repo-1::/inventory-elsewhere', + sessionId, + agent: 'codex', + activate: false + }) + } + const held = runtime.mobileSessionTabsByWorktree.get(WORKTREE_ID)! + const unrelated = runtime.mobileSessionTabsByWorktree.get('repo-1::/inventory-elsewhere')! + const rows = held.tabs + const otherRows = unrelated.tabs + const saved = session().unifiedTabs![WORKTREE_ID]! + const destination = rows.find((tab) => tab.type === 'terminal' && tab.id === destinationId) + if (!destination) { + throw new Error('missing completed destination') + } + const counts = { rows: 0, identity: 0, decorations: 0, saved: 0, unrelated: 0 } + held.tabs = new Proxy( + [...rows.filter((tab) => tab !== destination), destination].map( + (tab) => + new Proxy(tab, { + get(target, key, receiver) { + if (key === 'type' || key === 'id' || key === 'sessionId') { + counts.identity += 1 + } else if (typeof key === 'string') { + counts.decorations += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap forwards raw string|symbol keys with the original receiver. + return Reflect.get(target, key, receiver) + } + }) + ), + { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + counts.rows += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap forwards raw string|symbol keys with the original receiver. + return Reflect.get(target, key, receiver) + } + } + ) + session().unifiedTabs![WORKTREE_ID] = new Proxy(saved, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + counts.saved += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap forwards raw string|symbol keys with the original receiver. + return Reflect.get(target, key, receiver) + } + }) + unrelated.tabs = new Proxy(otherRows, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + counts.unrelated += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap forwards raw string|symbol keys with the original receiver. + return Reflect.get(target, key, receiver) + } + }) + try { + projection.mockClear() + expect(await request('fresh-history-after-close')).toEqual(replacement) + expect(counts).toEqual({ rows: 0, identity: 0, decorations: 0, saved: 0, unrelated: 0 }) + expect(await request(`scale-completed-${size}`)).toEqual(replacement) + expect(counts.rows).toBe(size) + expect(counts.rows + counts.identity).toBeLessThanOrEqual(3 * size) + expect({ + decorations: counts.decorations, + saved: counts.saved, + unrelated: counts.unrelated + }).toEqual({ decorations: 0, saved: 0, unrelated: 0 }) + expect(projection).not.toHaveBeenCalled() + } finally { + held.tabs = rows + session().unifiedTabs![WORKTREE_ID] = saved + unrelated.tabs = otherRows + } + } + } + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.ts new file mode 100644 index 000000000000..93169325d10c --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-execution.ts @@ -0,0 +1,295 @@ +import { retainStructuredSwitchSource } from './structured-agent-session-switch-retention' +import { structuredSessionSwitchFailure } from './structured-agent-session-switch-failure' +import { + captureStructuredSwitchWorkCheck, + prepareStructuredSessionSwitch, + requireStructuredSwitchRecord +} from './structured-agent-session-switch-preparation' +import type { z } from 'zod' +import type { RpcContext } from '../core' +import type { SwitchToChatParams, SwitchToTerminalParams } from './structured-agent-session-schemas' +import { + agentSessionSwitchDeadline, + type AgentSessionSwitchResult +} from '../../../../shared/agent-session-switch-execution' +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../../shared/agent-session-wire-refusals' +import { + nativeConversationContent, + prepareStructuredChatCliTranscript, + resolveStructuredChatCliSource +} from '../../../ai-vault/structured-chat-cli-fork' +import { structuredCallerFor } from './structured-agent-session-gate' +import { + prepareStructuredAgentSessionCreateForWorktree, + commitStructuredAgentSessionCreate +} from './structured-agent-session-create' +import { + captureStructuredSwitchTerminalSource, + recordWorkspace, + resolveStructuredSwitchRetainedOwner +} from './structured-agent-session-switch-source' +import { executeStructuredSessionTerminalSwitch } from './structured-agent-session-switch-terminal-execution' +import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication' +import { interruptWorkingAgent } from '../../terminal-agent-interrupt' +import { mainAgentState } from '../../../../shared/main-agent-status' +import { captureSwitchStoppedCliTurn } from '../../../native-chat/agent-session-wire/structured-agent-session-switch-import' +import { worktreeIdsEqual } from '../../../../shared/worktree/id' +export async function executeStructuredSessionSwitch( + params: z.infer | z.infer, + ctx: RpcContext, + direction: 'chat' | 'terminal' +): Promise { + const deadline = agentSessionSwitchDeadline() + let sessionId = params.sessionId + const selectionCurrent = ctx.runtime.captureSessionSwitchSelection( + undefined, + params.execution?.viewerSelection, + true + ) + let commandOwned = false + const operation = (async (): Promise => { + const prepared = await prepareStructuredSessionSwitch(params, ctx, sessionId, deadline) + const { + host, + execution, + create, + sourceEvidence, + createIdentity, + providerSessionId, + conversation + } = prepared + sessionId = prepared.sessionId + commandOwned = true + return host.runSwitch( + sessionId, + execution.invocationId, + deadline, + async () => { + if (direction === 'terminal') { + const record = requireStructuredSwitchRecord(host, sessionId) + return executeStructuredSessionTerminalSwitch( + params, + record, + ctx, + host, + sessionId, + deadline, + execution, + selectionCurrent + ) + } + deadline.check() + const source = 'source' in execution ? execution.source : undefined + const close = source + ? captureStructuredSwitchTerminalSource( + ctx.runtime, + host, + sessionId, + direction, + source.terminal, + source.expectedIncarnationId, + source.originalPresentation + ) + : undefined + const paneKey = close ? `${close.source.tabId}:${close.source.leafId}` : undefined + const row = paneKey ? ctx.runtime.agentTransitions.latestRow(paneKey) : null + const adopt = createIdentity?.adopt + const retainedSource = + !adopt && source + ? await prepareStructuredChatCliTranscript( + await resolveStructuredChatCliSource( + { sessionId, workspaceId: recordWorkspace(host, sessionId) }, + false + ), + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + : undefined + const before = adopt?.transcriptPath + ? { + agent: create!.agent, + sessionId: adopt.providerHandle.nativeId, + filePath: adopt.transcriptPath, + source: adopt.source + } + : retainedSource?.cliForkSource + const stopped = before + ? await captureSwitchStoppedCliTurn( + before, + Boolean(row && mainAgentState(row) === 'working'), + deadline + ) + : undefined + deadline.check() + if (close) { + if (paneKey) { + await interruptWorkingAgent( + ctx.runtime, + (key) => ctx.runtime.agentTransitions.latestRow(key), + paneKey, + { + signal: deadline.signal, + beforeWrite: async () => { + deadline.check() + } + } + ) + } + // Shutdown already accepted must finish even when the observing request expires. + try { + await host.trackSwitchPhysical(sessionId, close.stop()) + } catch { + host.deps.logger.warn('switch source exit was not confirmed', { + scope: 'session-switch', + sessionId, + direction, + source: 'terminal', + exactIncarnation: true + }) + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + } + deadline.check() + if (create) { + try { + const preparedCreate = await prepareStructuredAgentSessionCreateForWorktree({ + ...create, + runtime: ctx.runtime, + caller: structuredCallerFor(ctx), + atRest: true, + ensureHost: async () => host, + structuredAgentCliSourceEvidence: sourceEvidence + }) + deadline.check() + const created = await commitStructuredAgentSessionCreate({ + runtime: ctx.runtime, + caller: structuredCallerFor(ctx), + prepared: { ...preparedCreate, tab: null }, + activate: false, + check: deadline.check + }) + if (!created.ok) { + throw new AgentSessionRefusalError(created.refusal) + } + sessionId = created.value.sessionId + } catch (error) { + sessionId = resolveStructuredSwitchRetainedOwner( + error, + host, + createIdentity, + providerSessionId + ) + } + await host.adoptSwitch(conversation?.commandKey ?? params.sessionId, sessionId) + } + const record = requireStructuredSwitchRecord(host, sessionId) + const check = captureStructuredSwitchWorkCheck(host, sessionId, deadline, direction) + if ( + source || + nativeConversationContent(await host.journalSnapshot(sessionId)) === 'saved' + ) { + const transcript = await prepareStructuredChatCliTranscript( + await resolveStructuredChatCliSource( + { sessionId, workspaceId: record.location.workspaceId }, + false + ), + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + check() + if (!transcript.cliForkSource) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) + } + await host.refreshSwitchTranscript( + sessionId, + transcript.cliForkSource, + deadline, + stopped, + check + ) + } + const revealed = await host.revealSession(sessionId) + check() + if (!revealed.readable && revealed.openRefusal) { + throw new AgentSessionRefusalError(revealed.openRefusal) + } + const replaceSource = + close && worktreeIdsEqual(close.source.worktreeId, record.location.workspaceId) + const placement = + (replaceSource ? close.placement() : close ? undefined : create?.tabPlacement) ?? {} + await ctx.runtime.publishStructuredAgentSessionTab({ + ...revealed, + activate: true, + check, + selectionCurrent, + viewerSelection: execution.viewerSelection, + focusWindow: agentLaunchMovesHostWindow(ctx), + placement, + openPlacement: { + invocationId: execution.invocationId, + generation: host.switchGeneration(sessionId)!, + placement, + activate: true, + viewerSelection: execution.viewerSelection, + ...(replaceSource ? { source: close.source } : {}) + } + }) + deadline.check() + if (replaceSource) { + await close.retire() + } + deadline.check() + // Publication is complete; a first new send may already own the ordinary start. + const starting = await host.openSwitchToChat(sessionId, deadline) + if (starting.owned) { + void host.trackSwitchPhysical(sessionId, starting.outcome).catch(() => {}) + } else { + void starting.outcome.catch(() => {}) + } + return { status: 'completed', sessionId } + }, + { + direction, + onAccepted: selectionCurrent.accept, + conversationKey: conversation?.commandKey, + allowUncreated: Boolean(create), + destinationCurrent: (result) => { + const record = host.deps.store.getRecord(result.sessionId) + return record + ? ctx.runtime.currentSessionSwitchDestination(record.location.workspaceId, result) + : undefined + }, + onRetained: + direction === 'terminal' + ? (check) => + retainStructuredSwitchSource( + ctx.runtime, + host, + sessionId, + execution.invocationId, + check + ) + : undefined + } + ) + })() + void operation + .finally(() => { + if (!commandOwned) { + deadline.dispose() + } + }) + .catch(() => {}) + try { + return await deadline.wait(operation) + } catch (error) { + return structuredSessionSwitchFailure(error, sessionId) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-failure.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-failure.ts new file mode 100644 index 000000000000..e746fdc5c2d8 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-failure.ts @@ -0,0 +1,33 @@ +import { DaemonConnectionLostError, DaemonRequestTimeoutError } from '../../../daemon/daemon-errors' +import { isAgentSessionOperationOutcomeUnknown } from '../../runtime-agent-launch-resolution' +import type { AgentSessionSwitchResult } from '../../../../shared/agent-session-switch-execution' +import { + agentSessionRefusalError, + isAgentSessionRefusalError, + refuseUnclassified +} from '../../../../shared/agent-session-wire-refusals' +export function structuredSessionSwitchFailure( + error: unknown, + sessionId: string +): AgentSessionSwitchResult { + const refusal = isAgentSessionRefusalError(error) + ? error.refusal + : isAgentSessionOperationOutcomeUnknown(error) || + error instanceof DaemonConnectionLostError || + error instanceof DaemonRequestTimeoutError + ? agentSessionRefusalError('agent_session_operation_unknown', { reason: 'outcomeUnknown' }) + .refusal + : refuseUnclassified( + 'agent_session_owner_restart_failed', + 'The agent could not switch. Try again.' + ) + return { + status: + refusal.code === 'agent_session_operation_expired' || + refusal.code === 'agent_session_operation_unknown' + ? 'uncertain' + : 'refused', + sessionId, + refusal + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-preparation.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-preparation.ts new file mode 100644 index 000000000000..70be260d4e1b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-preparation.ts @@ -0,0 +1,123 @@ +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { z } from 'zod' +import type { RpcContext } from '../core' +import type { SwitchToChatParams, SwitchToTerminalParams } from './structured-agent-session-schemas' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { + ensureStructuredHostInstalled, + requireStructuredSessionHost, + requireStructuredHost, + structuredCallerFor +} from './structured-agent-session-gate' +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { + switchRetainedOwner, + switchConversationIdentity +} from './structured-agent-session-switch-source' +import { captureStructuredAgentCliSource } from '../../../native-chat/structured-agent-cli-source' +import { agentSessionProviderHandleChainHead } from '../../../../shared/agent-session-provider-handle' +import { structuredAgentRuntimeMetadata } from '../../structured-agent-runtime-metadata' +import type { AgentSessionSwitchDeadline } from '../../../../shared/agent-session-switch-execution' +export async function prepareStructuredSessionSwitch( + params: z.infer | z.infer, + ctx: RpcContext, + sessionId: string, + deadline: AgentSessionSwitchDeadline +) { + await ensureStructuredHostInstalled(ctx) + deadline.check() + const execution = params.execution + if (!execution) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + const create = 'create' in execution ? execution.create : undefined + const host = create + ? requireStructuredHost(ctx, create.agent) + : requireStructuredSessionHost(ctx, sessionId) + if ( + create && + (create.envelope.sessionId !== params.sessionId || !inPlaceSwitchEnabled(create.agent)) + ) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + const sourceEvidence = + ctx.structuredAgentCliSourceEvidence ?? + (create?.resumeFrom?.cliSource + ? captureStructuredAgentCliSource( + () => ctx.runtime.getAgentProviderSessionRows(), + create.resumeFrom.cliSource, + (source) => ctx.runtime.getStructuredAgentCliSourceRoute(source) + ) + : undefined) + const createIdentity = create + ? await ctx.runtime.resolveStructuredAgentSessionCreateIdentity({ + ...create, + callerKey: structuredCallerFor(ctx).callerKey, + structuredAgentCliSourceEvidence: sourceEvidence + }) + : undefined + const identity = createIdentity ?? host.deps.store.getRecord(sessionId) + const providerSessionId = + create?.resumeFrom?.providerSessionId ?? + (() => { + const record = host.deps.store.getRecord(sessionId) + const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain) + return ( + head && structuredAgentRuntimeMetadata(record!.provider)?.sessionHistory?.rowSessionId(head) + ) + })() + const conversation = + identity && providerSessionId + ? switchConversationIdentity(identity, providerSessionId) + : undefined + if (create && identity && providerSessionId) { + sessionId = switchRetainedOwner(host, identity, providerSessionId) ?? sessionId + } + + return { + sessionId, + host, + execution, + create, + sourceEvidence, + createIdentity, + providerSessionId, + conversation + } +} + +export function requireStructuredSwitchRecord(host: StructuredAgentSessionHost, sessionId: string) { + const record = host.deps.store.getRecord(sessionId) + if (!record || !inPlaceSwitchEnabled(record.provider)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return record +} + +export function captureStructuredSwitchWorkCheck( + host: StructuredAgentSessionHost, + sessionId: string, + deadline: AgentSessionSwitchDeadline, + direction: 'chat' | 'terminal' +): () => void { + const workCurrent = host.captureSwitchWork(sessionId, deadline) + return () => { + deadline.check() + if (!workCurrent()) { + host.deps.logger.warn('switch destination was superseded by newer work', { + scope: 'session-switch', + sessionId, + direction + }) + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-retention.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-retention.ts new file mode 100644 index 000000000000..be4da359ad14 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-retention.ts @@ -0,0 +1,28 @@ +import type { RpcContext } from '../core' +import { recordWorkspace } from './structured-agent-session-switch-source' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +export async function retainStructuredSwitchSource( + runtime: RpcContext['runtime'], + host: StructuredAgentSessionHost, + sessionId: string, + invocationId: string, + check: () => void +): Promise { + if (!host.deps.store.getRecord(sessionId)) { + return + } + check() + const revealed = await host.revealSession(sessionId) + check() + await runtime.publishStructuredAgentSessionTab({ + ...revealed, + activate: false, + check + }) + runtime.retainSessionSwitchProjection( + recordWorkspace(host, sessionId), + sessionId, + invocationId, + host.switchGeneration(sessionId) ?? invocationId + ) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-source.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-source.ts new file mode 100644 index 000000000000..f1991e84419b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-source.ts @@ -0,0 +1,110 @@ +import type { RpcContext } from '../core' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' + +import { + agentSessionRefusalError, + isAgentSessionRefusalError +} from '../../../../shared/agent-session-wire-refusals' + +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { + conversationKeyId, + keysMayMatch, + terminalConversationKey +} from '../../../native-chat/structured-conversation-key' +import { canonicalAgentSessionDigest } from '../../../../shared/agent-session-mutation-envelope' + +export function recordWorkspace(host: StructuredAgentSessionHost, id: string) { + const record = host.deps.store.getRecord(id) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) + } + return record.location.workspaceId +} + +/** Registered History IDs include file-backed agents; account and host remain part of identity. */ +export function switchConversationIdentity( + identity: Pick, + providerSessionId: string +) { + const key = { + ...terminalConversationKey(identity.provider, providerSessionId), + home: identity.accountHome + } + return { + key, + commandKey: `conversation:${canonicalAgentSessionDigest({ + conversation: { ...key, id: conversationKeyId(key) }, + executionHostId: identity.location.executionHostId, + wslDistro: identity.location.wslDistro + })}` + } +} + +export function switchRetainedOwner( + host: StructuredAgentSessionHost, + identity: Pick, + providerSessionId: string +): string | null { + const { key } = switchConversationIdentity(identity, providerSessionId) + return ( + listStructuredProviderSessionOwnership(host.deps.store.listConversations()).find((owner) => { + const record = host.deps.store.getRecord(owner.sessionId) + return ( + owner.role === 'head' && + keysMayMatch(owner.key, key) && + record && + record.location.executionHostId === identity.location.executionHostId && + record.location.wslDistro === identity.location.wslDistro + ) + })?.sessionId ?? null + ) +} + +export function resolveStructuredSwitchRetainedOwner( + error: unknown, + host: StructuredAgentSessionHost, + identity: Parameters[1] | undefined, + providerSessionId: string | null | undefined +): string { + const owner = + isAgentSessionRefusalError(error) && + (error.refusal.code === 'agent_session_conflict' || + error.refusal.code === 'agent_session_ownership_unknown') && + identity && + providerSessionId && + switchRetainedOwner(host, identity, providerSessionId) + if ( + !owner || + !isAgentSessionRefusalError(error) || + error.refusal.details?.owner?.sessionId !== owner + ) { + throw error + } + return owner +} + +/** Translate only an unconfirmed exact terminal capture into the existing ownership refusal. */ +export function captureStructuredSwitchTerminalSource( + runtime: RpcContext['runtime'], + host: StructuredAgentSessionHost, + sessionId: string, + direction: 'chat' | 'terminal', + ...source: Parameters +) { + try { + return runtime.captureTerminalSwitchClose(...source) + } catch { + host.deps.logger.warn('switch source owner was not confirmed', { + scope: 'session-switch', + sessionId, + direction, + source: 'terminal', + exactIncarnation: false + }) + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-terminal-execution.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-terminal-execution.ts new file mode 100644 index 000000000000..70a3bf534d17 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-terminal-execution.ts @@ -0,0 +1,147 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { z } from 'zod' +import type { RpcContext } from '../core' +import { agentLaunchTabViewerRule } from './agent-launch-tab-publication' +import { SwitchToTerminalParams } from './structured-agent-session-schemas' +import type { + agentSessionSwitchDeadline, + AgentSessionSwitchResult +} from '../../../../shared/agent-session-switch-execution' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { + nativeConversationContent, + prepareStructuredChatCliTranscript, + resolveStructuredChatCliSource +} from '../../../ai-vault/structured-chat-cli-fork' + +export async function executeStructuredSessionTerminalSwitch( + params: z.infer, + record: AgentSessionRecord, + ctx: RpcContext, + host: StructuredAgentSessionHost, + sessionId: string, + deadline: ReturnType, + execution: NonNullable['execution']>, + selectionCurrent: (consume?: boolean) => boolean +): Promise { + const agent = record.provider + if (!isTuiAgent(agent)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const terminalExecution = SwitchToTerminalParams.parse(params).execution + const sourceTabId = terminalExecution?.sourceTabId + let workCurrent = () => false + const retire = ctx.runtime.captureSessionSwitchRetirement( + record.location.workspaceId, + sessionId, + sourceTabId, + () => workCurrent(), + () => host.retainSwitch(sessionId), + { + invocationId: execution.invocationId, + generation: host.switchGeneration(sessionId)!, + sessionId, + tabId: sourceTabId ?? `agent-session:${sessionId}`, + phase: 'prepare' + } + ) + workCurrent = await host.stopForSwitch( + sessionId, + { callerKey: 'trusted-local:session-switch' }, + deadline + ) + host.guardSwitchWork(sessionId, workCurrent) + const snapshot = await host.journalSnapshot(sessionId) + deadline.check() + const content = nativeConversationContent(snapshot) + if (content === 'uncertain') { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + const prepared = + content === 'saved' + ? await prepareStructuredChatCliTranscript( + await resolveStructuredChatCliSource( + { sessionId, workspaceId: record.location.workspaceId }, + false + ), + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + : undefined + deadline.check() + if (content === 'saved' && !prepared?.cliForkSource) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) + } + deadline.check() + const placement = retire.placement() ?? { + afterTabId: sourceTabId, + groupId: terminalExecution?.groupId + } + const terminal = await ctx.runtime.createSessionSwitchTerminal(record.location.workspaceId, { + invocationId: execution.invocationId, + sessionId, + agent, + source: prepared?.cliForkSource, + commandOverride: terminalExecution?.commandOverride, + options: record.options, + afterTabId: sourceTabId, + groupId: terminalExecution?.groupId, + execution: { + invocationId: execution.invocationId, + openPlacement: { + invocationId: execution.invocationId, + generation: host.switchGeneration(sessionId)!, + placement, + activate: true, + viewerSelection: execution.viewerSelection + }, + signal: deadline.signal, + trackPhysical: (work, recover) => host.trackSwitchPhysical(sessionId, work, recover), + remaining: deadline.remaining, + check: () => { + deadline.check() + if (!workCurrent()) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'messagesUnsettled' + }) + } + }, + selectionCurrent, + viewerSelection: execution.viewerSelection, + viewer: agentLaunchTabViewerRule(ctx, 'focused') + } + }) + if (terminal.agentResumeRefused || terminal.agentResumeUnavailable) { + return { + status: 'refused', + sessionId, + refusal: agentSessionRefusalError('agent_session_operation_unknown', { + reason: 'outcomeUnknown' + }).refusal + } + } + if (terminal.switchOutcomeUnknown) { + return { + status: 'uncertain', + sessionId, + refusal: agentSessionRefusalError('agent_session_operation_unknown', { + reason: 'outcomeUnknown' + }).refusal + } + } + const retired = await retire() + return { + status: 'completed', + sessionId, + terminal: terminal.tab.id, + sourceRetained: Boolean(sourceTabId && !retired) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts new file mode 100644 index 000000000000..327f2c23e725 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts @@ -0,0 +1,52 @@ +import { executeStructuredSessionSwitch } from './structured-agent-session-switch-execution' +import type { AgentSessionSwitchResult } from '../../../../shared/agent-session-switch-execution' +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { refuse } from '../../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' +import { defineMethod } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredCapability, + requireStructuredSessionHost +} from './structured-agent-session-gate' +import { SwitchToChatParams } from './structured-agent-session-schemas' + +// Optional execution uses the complete switch contract; strict legacy calls keep their envelope. + +export const STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS = [ + defineMethod({ + name: 'agentSession.switchToChat', + permission: 'workspace', + params: SwitchToChatParams, + handler: async ( + params, + ctx + ): Promise< + | { ok: true; sessionId: string } + | { ok: false; refusal: AgentSessionWireRefusal } + | AgentSessionSwitchResult + > => { + requireStructuredCapability(ctx) + if (params.execution) { + return executeStructuredSessionSwitch(params, ctx, 'chat') + } + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredSessionHost(ctx, params.sessionId) + const record = host.deps.store.getRecord(params.sessionId) + if (!record || !inPlaceSwitchEnabled(record.provider)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This chat cannot take its conversation from a terminal on this host.' + ) + } + } + const started = await host.switchToChat(params.sessionId) + return started.ok + ? { ok: true, sessionId: params.sessionId } + : { ok: false, refusal: started.refusal } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts new file mode 100644 index 000000000000..704d148ad42c --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts @@ -0,0 +1,55 @@ +import { executeStructuredSessionSwitch } from './structured-agent-session-switch-execution' +import type { AgentSessionSwitchResult } from '../../../../shared/agent-session-switch-execution' +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import type { AiVaultChatForkSource } from '../../../../shared/ai-vault-resume-preparation' +import { + prepareStructuredChatCliTranscript, + resolveStructuredChatCliSource +} from '../../../ai-vault/structured-chat-cli-fork' +import { defineMethod } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredCapability, + requireStructuredSessionHost +} from './structured-agent-session-gate' +import { SwitchToTerminalParams } from './structured-agent-session-schemas' + +// Prepare the ordinary same-id resume recipe, then join Stop and positive physical exit. + +export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ + defineMethod({ + name: 'agentSession.switchToTerminal', + permission: 'workspace', + params: SwitchToTerminalParams, + handler: async ( + params, + ctx + ): Promise<{ source: AiVaultChatForkSource } | AgentSessionSwitchResult> => { + requireStructuredCapability(ctx) + if (params.execution) { + return executeStructuredSessionSwitch(params, ctx, 'terminal') + } + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredSessionHost(ctx, params.sessionId) + const record = host.deps.store.getRecord(params.sessionId) + if (!record || !inPlaceSwitchEnabled(record.provider)) { + throw new Error('This chat cannot switch to a terminal on this host.') + } + // Before anything stops: the source, a route that can hold the start, and its recipe. + const source = await resolveStructuredChatCliSource( + { sessionId: record.sessionId, workspaceId: record.location.workspaceId }, + false + ) + const prepared = await prepareStructuredChatCliTranscript( + source, + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + if (!prepared.cliForkSource) { + throw new Error('This chat has no saved conversation available on this host.') + } + await host.switchToTerminal(params.sessionId) + return { source: prepared.cliForkSource } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index bfa3e95494ef..804a6c9be9fe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -169,7 +169,7 @@ describe('capability gating', () => { expect(names).toContain(method.name) } // Keep protocol stable: structured capability already hides additive session methods from older clients. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(43) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(45) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 9c295ea7b034..f4f94354b78a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -1,3 +1,4 @@ +import { STRUCTURED_AGENT_SESSION_SLEEP_WORKSPACE_METHODS } from './structured-agent-session-sleep-workspace' import { clientRequestsCommands } from './structured-agent-session-policy' import { clientReadsCurrentRequests, @@ -52,6 +53,8 @@ import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' import { STRUCTURED_AGENT_SESSION_ITEM_METHODS } from './structured-agent-session-item' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' +import { STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS } from './structured-agent-session-switch-to-terminal' +import { STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS } from './structured-agent-session-switch-to-chat' import { AttachParams, CancelParams, @@ -62,13 +65,11 @@ import { OptionsParams, RewindParams, SendParams, - SleepWorkspaceParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { noteStructuredAgentSessionClose, @@ -168,55 +169,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), - defineMethod({ - name: 'agentSession.sleepWorkspace', - permission: 'workspace', - params: SleepWorkspaceParams, - handler: async (params, ctx) => { - requireStructuredCapability(ctx) - if (params.markAsleep) { - return (await requireInstalledHost(ctx)).sleepWorkspace(params.workspaceId, true) - } - const installed = getStructuredAgentSessionHost() - if (installed) { - return installed.sleepWorkspace(params.workspaceId, params.markAsleep) - } - let store: Awaited> - try { - store = await ctx.runtime.openAgentSessionRecordStore() - } catch (error) { - const appeared = getStructuredAgentSessionHost() - if (appeared) { - return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) - } - console.warn('[agent-session] workspace sleep record inventory unavailable', error) - return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } - } - const appeared = getStructuredAgentSessionHost() - if (appeared) { - return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) - } - try { - const records = store - .listConversations() - .filter( - (record) => - record.location.workspaceId === params.workspaceId && - record.location.executionHostId === LOCAL_EXECUTION_HOST_ID - ) - const stoppedSessionIds = records - .filter((record) => record.neverStarted) - .map((record) => record.sessionId) - const failures = records - .filter((record) => !record.neverStarted) - .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) - return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } - } catch (error) { - console.warn('[agent-session] workspace sleep record inventory failed', error) - return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } - } - } - }), + ...STRUCTURED_AGENT_SESSION_SLEEP_WORKSPACE_METHODS, ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', @@ -260,8 +213,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ readsCurrentRequests: clientReadsCurrentRequests(ctx), id: subscriptionId, sessionId: params.sessionId, - projectQueuePublication: (publication) => - projectQueuedImageCardPublication(publication, ctx), + projectQueuePublication: (queued) => projectQueuedImageCardPublication(queued, ctx), emit: (event) => emit( projectTurnItemEvent( @@ -307,5 +259,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS, ...STRUCTURED_AGENT_SESSION_ITEM_METHODS, ...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS, - ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS + ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS, + ...STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS, + ...STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index da37dd06febe..3996f38ba181 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -14,6 +14,7 @@ import { TerminalCreateParams, TerminalFocus, TerminalHandle, + TerminalClose, TerminalReveal, TerminalSplit, TerminalWait @@ -230,14 +231,21 @@ export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.close', permission: 'workspace', - params: TerminalHandle, + params: TerminalClose, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( 'terminal.close', context, 'terminal', params.terminal, - () => context.runtime.closeTerminal(params.terminal) + () => + params.originalPresentation + ? context.runtime.closeTerminal( + params.terminal, + params.expectedIncarnationId, + params.originalPresentation + ) + : context.runtime.closeTerminal(params.terminal, params.expectedIncarnationId) ) }) }), diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts index 12290423a750..bf8ba2f50551 100644 --- a/src/main/runtime/rpc/runtime-close-attribution.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -88,7 +88,9 @@ describe('runtime close attribution', () => { ) expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true }) - expect(close).toHaveBeenCalledWith('term-1') + expect(close).toHaveBeenCalledWith( + ...(method === 'terminal.close' ? ['term-1', undefined] : ['term-1']) + ) expect(sink.records).toEqual([ expect.objectContaining({ type: 'effect-span', diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts index 26cd7bcdb4e5..f0a5c6fa8f4a 100644 --- a/src/main/runtime/runtime-durable-store-fixture.ts +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -107,8 +107,15 @@ export function withRuntimePaneAdmission< return value } return Object.assign(store, { - admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) => - commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)), + admitTerminalPane: async ( + admission: TerminalPaneAdmission, + hostId?: string, + check?: () => void + ) => + commit(hostId, (session) => { + check?.() + return admitPaneToSession(resolveHostId(hostId), session, admission) + }), withdrawTerminalPane: async ( pane: CommandOf<'closePane'>, hostId?: string, diff --git a/src/main/runtime/runtime-fake-spawn-binding.test-fixture.ts b/src/main/runtime/runtime-fake-spawn-binding.test-fixture.ts new file mode 100644 index 000000000000..cfc4cd28e9a9 --- /dev/null +++ b/src/main/runtime/runtime-fake-spawn-binding.test-fixture.ts @@ -0,0 +1,112 @@ +import { getConnectionExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' +import { ptyBindingIsRefused } from '../persistence/loading-store/pty-binding-refusals' +import { applyPtyBinding } from '../persistence/loading-store/pty-binding-session-update' +import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation' +import { asProfileSessionStore, storeOf } from './profile-session-store.test-fixture' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' + +type SpawnArgs = Parameters>[0] + +/** A fake controller replaces only the pane admitted for this spawn, like the lower owner. */ +export function captureFakeSpawnBinding( + runtime: OrcaRuntimeService, + args: Pick< + SpawnArgs, + 'worktreeId' | 'tabId' | 'leafId' | 'connectionId' | 'expectedSourceBinding' + >, + explicitHostId?: ExecutionHostId +): (result: unknown) => void { + const store = storeOf(runtime) + if (!store || !args.worktreeId || !args.tabId || !args.leafId) { + return () => {} + } + const { worktreeId, tabId, leafId } = args + const holdsPane = (hostId: ExecutionHostId): boolean => { + const session = store.getWorkspaceSession?.(hostId) + return ( + (session?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? false) && + layoutContainsLeafId(session?.terminalLayoutsByTabId[tabId]?.root ?? null, leafId) + ) + } + const admitted = Reflect.has(store, 'admitTerminalPane') + const hostId = + explicitHostId ?? + (admitted + ? [getConnectionExecutionHostId(args.connectionId), 'local' as const].find(holdsPane) + : 'local') + if (!hostId) { + return () => {} + } + const sessions = asProfileSessionStore(store, hostId) + const before = sessions.get(hostId) + const paneKey = `${tabId}:${leafId}` + const predecessor = before.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + const predecessorStamp = before.terminalPtyIncarnationsByPaneKey?.[paneKey] + return (result) => { + if ( + !result || + typeof result !== 'object' || + !('id' in result) || + typeof result.id !== 'string' + ) { + return + } + const incarnationId = + 'incarnationId' in result && typeof result.incarnationId === 'string' + ? result.incarnationId + : undefined + try { + runtime.assertPtyRegistrationAllowed(result.id, incarnationId) + } catch (error) { + runtime.releaseRejectedPtyRegistrationFence(result.id, incarnationId) + throw error + } + const current = sessions.get(hostId) + const bound = current.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + const stamp = current.terminalPtyIncarnationsByPaneKey?.[paneKey] + // Explicit committingSpawn and the common controller wrapper may observe the same commit. + if (bound === result.id && stamp === incarnationId) { + return + } + const binding = { + worktreeId, + tabId, + leafId, + ptyId: result.id, + ...(incarnationId ? { incarnationId } : { dropsIncarnation: true as const }), + ...(predecessor + ? { expectedBinding: { ptyId: predecessor, incarnationId: predecessorStamp } } + : {}), + ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}), + mayCreate: !admitted, + mayReviveRetiredSurface: false + } + if ( + (!predecessor && bound !== undefined) || + ptyBindingIsRefused( + binding, + current, + worktreeId, + paneKey, + (store.getWorkspaceSessionHostIds?.() ?? [hostId]).map((hostId) => ({ + hostId, + session: sessions.get(hostId) + })) + ) + ) { + // The fake lower owner discards its refused child before returning, as the real provider does. + try { + runtime['ptyController']?.kill(result.id) + } catch { + // Cleanup cannot replace the original binding refusal. + } + throw args.expectedSourceBinding + ? new Error('terminal_split_source_not_found') + : new PaneOwnerContentionError() + } + applyPtyBinding(binding, current, worktreeId, paneKey) + sessions.set(current, hostId) + } +} diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 67cf981cac1c..7ddea427f4f6 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -77,11 +77,11 @@ export async function applyWindowLayoutCommand( // Window closes already confirmed/stopped terminals: force removal without killing shared PTYs. Honor pinned-tab refusals from CLI/new phones. // Read intent before close removes the record. const terminalTabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabIds[0]) - const reply = await store.applyLayoutCommand( - { ...command, force: command.refusePinned ? command.force === true : true }, - holders - ) - // Cancel launches only for tabs actually closed; refused pinned tabs still receive theirs. + const close = { ...command, force: command.refusePinned ? command.force === true : true } + const reply = await (run + ? store.applyLayoutCommand(close, holders, run) + : store.applyLayoutCommand(close, holders)) + // Only a tab the close took: a refused (pinned) tab's launches still finish into it. if ( command.recordClose === 'user' && reply.ok && @@ -134,12 +134,20 @@ export async function applyWindowLayoutCommand( : store.applyLayoutCommand(command, target) } const { workspace, tabId, leafId } = command - // Cancel pending launches in user-closed panes, as legacy close did. - markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) - return store.applyLayoutCommand( - { type: 'closePane', workspace, tabId, leafId, userPaneClose: true }, - holders - ) + // A launch still starting in the pane the user closed stops, and says so (as the legacy close). + if (!run?.originalPresentation && !run?.admit) { + markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) + } + const close: CommandOf<'closePane'> = { + type: 'closePane', + workspace, + tabId, + leafId, + userPaneClose: true + } + return run + ? store.applyLayoutCommand(close, holders, run) + : store.applyLayoutCommand(close, holders) } /** Rename in each tab's routed partition in one durable write; untitled reports the same refusal codes as renameTab. */ diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 3d6cf075c2fd..af9847842c98 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -284,6 +284,7 @@ export function projectRuntimeMobileSessionTabs( : {} tabs.push({ type: 'terminal', + ...(tab.openPlacement ? { openPlacement: tab.openPlacement } : {}), id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, diff --git a/src/main/runtime/runtime-mobile-session-result-finalization.ts b/src/main/runtime/runtime-mobile-session-result-finalization.ts index e35949780551..fc8167245488 100644 --- a/src/main/runtime/runtime-mobile-session-result-finalization.ts +++ b/src/main/runtime/runtime-mobile-session-result-finalization.ts @@ -34,6 +34,7 @@ export function finalizeRuntimeMobileSessionTabsResult( null) return { worktree: snapshot.worktree, + ...(snapshot.switchProjections ? { switchProjections: snapshot.switchProjections } : {}), publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion, activeGroupId, diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index c1656c3d36ed..12eb423acc68 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import type { RuntimeEditorTabReveal, RuntimeFileOpenPosition @@ -29,6 +30,7 @@ import type { RuntimeTerminalDriverState, RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { SessionSwitchProjection } from '../../shared/agent-session-switch-execution' type DriverState = RuntimeTerminalDriverState @@ -130,8 +132,12 @@ export type RuntimeNotifier = { focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void /** Raises the window onto a pane it shows; resolves false when the window does not know the worktree. */ revealTerminalPane?(target: TerminalPaneRevealTarget): Promise - focusEditorTab?(tabId: string, worktreeId: string): void - closeSessionTab?(tabId: string, worktreeId: string): void | Promise + focusEditorTab?(tabId: string, worktreeId: string, viewerSelection?: string): void + closeSessionTab?( + tabId: string, + worktreeId: string, + switchProjection?: SessionSwitchProjection + ): void | Promise moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void /** Host window only: host/all move it; caller/clients open without moving it; absence keeps legacy switching. Paired clients never navigate (all means host). */ openFile?( @@ -159,7 +165,11 @@ export type RuntimeNotifier = { closeTerminalPane?(tabId: string, leafId: string): void closeTerminalTab?( tabId: string, - options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } + options?: { + localPtyTeardownOwnedExternally?: boolean + force?: boolean + originalSurface?: TerminalSurfaceCloseIdentity + } ): Promise /** A sleep main runs, for the window to follow; optional as only the real bridge wires it. */ workspaceSleepChanged?(notice: WorkspaceSleepNotice): void diff --git a/src/main/runtime/runtime-pane-admission.test.ts b/src/main/runtime/runtime-pane-admission.test.ts index 662be255e886..0bdae8114d76 100644 --- a/src/main/runtime/runtime-pane-admission.test.ts +++ b/src/main/runtime/runtime-pane-admission.test.ts @@ -36,6 +36,7 @@ describe('startInAdmittedPane', () => { result: 'term-1' }) expect(store.admitTerminalPane).toHaveBeenCalledWith(SPLIT, 'ssh:target-1') + expect(store.admitTerminalPane.mock.contexts).toEqual([runtimeStore]) expect(start).toHaveBeenCalledWith('admitted') }) diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index e0633ad9e415..68f973f588a7 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -23,6 +23,9 @@ type Spawn = NonNullable type PaneStartStatusSink = Pick /** Main-owned/keepOnFailure panes retain failed start status. Each start clears prior resume outcome; only resumesSleepingRecord publishes a new one. */ export type PaneStartArgs = { + beforeAdmission?: () => void + beforeSpawn?: () => void + keepOnFailure?: boolean startStatus?: { sink: PaneStartStatusSink keepOnFailure: boolean @@ -118,9 +121,13 @@ export async function withdrawAdmittedPane( export async function admitRuntimePane( store: RuntimeStore | null | undefined, admission: TerminalPaneAdmission, - hostId: string | undefined + hostId: string | undefined, + check?: () => void ): Promise { - return store?.admitTerminalPane?.(admission, hostId) + const args: Parameters> = check + ? [admission, hostId, check] + : [admission, hostId] + return store?.admitTerminalPane?.(...args) } /** Admit before starting (design 4.1); withdraw failed newly admitted panes unless keepOnFailure retains the error surface. */ @@ -129,9 +136,10 @@ export async function startInAdmittedPane( admission: TerminalPaneAdmission, hostId: string | undefined, start: (outcome: TerminalPaneAdmissionOutcome | undefined) => Promise, - keepOnFailure = false + keepOnFailure = false, + check?: () => void ): Promise<{ outcome: TerminalPaneAdmissionOutcome | undefined; result: T }> { - const outcome = await admitRuntimePane(store, admission, hostId) + const outcome = await admitRuntimePane(store, admission, hostId, check) try { return { outcome, result: await start(outcome) } } catch (error) { @@ -147,7 +155,13 @@ export async function startInAdmittedPane( export async function spawnInAdmittedPane( store: RuntimeStore | null | undefined, controller: Pick, - { startStatus, ...args }: AdmittedSpawnArgs & PaneStartArgs + { + startStatus, + beforeAdmission, + beforeSpawn, + keepOnFailure, + ...args + }: AdmittedSpawnArgs & PaneStartArgs ): ReturnType { const { groupId: _groupId, afterTabId: _afterTabId, ...spawnArgs } = args const paneKey = `${args.tabId}:${args.leafId}` @@ -182,6 +196,7 @@ export async function spawnInAdmittedPane( admitted === 'tab_closed' ? 'tab_not_found' : 'terminal_split_source_not_found' ) } + beforeSpawn?.() admittedAs = admitted // SAFETY: Callers require spawn; a lost capability during admission keeps the original failure. return controller.spawn!(spawnArgs) @@ -195,7 +210,8 @@ export async function spawnInAdmittedPane( admissionFor(args), hostIdOf(args), start, - startStatus?.keepOnFailure + keepOnFailure || startStatus?.keepOnFailure, + beforeAdmission ) } catch (error) { if ( @@ -226,7 +242,10 @@ export async function spawnInAdmittedPane( } startStatus?.sink.clear(paneKey) const { outcome, result } = run - const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner + const started = + result.switchRecoveredSurface ?? + result.agentSessionEnsure?.owner.surface ?? + result.stablePaneOwner const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId) if (outcome === 'admitted' && elsewhere) { await withdrawRuntimePane(store, args) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 9017a10c7df8..3df04cf3c853 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -16,7 +16,6 @@ import type { import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyBindingSourceExpectation } from '../persistence' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../shared/execution-host' @@ -25,11 +24,19 @@ import type { PtyProcessInfo, PtySpawnResult } from '../providers/pty-provider-contract' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { + PtyProcessInspection, + PtyProcessInspectionOptions +} from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { StartupCwdFallback } from '../../shared/startup-cwd-fallback' import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { PaneResumeOutcome } from '../../shared/workspace-layout/workspace-layout-published' +import type { + PtyFencedTerminationRequest, + PtyFencedTerminationOutcome +} from '../../shared/pty-fenced-termination' +import type { AgentSessionSwitchAuthority } from '../../shared/agent-session-switch-execution' export type PtyInventoryRefreshOptions = { includeForegroundProcessEvidence?: boolean @@ -46,6 +53,7 @@ export type RuntimePtyController = { adoptStablePane?(opts: { onOutcome?: (outcome: PtySpawnOutcome, operation: PaneSpawnOperation) => void signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority spawnFresh?: (operation: PaneSpawnOperation) => Promise cols: number rows: number @@ -79,7 +87,7 @@ export type RuntimePtyController = { env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -112,6 +120,7 @@ export type RuntimePtyController = { agentSessionCreateOperationId?: string spawnIntent?: SpawnIntent signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void /** Layout-owner build: whether a fresh start resumed its agent, told before its bind is written. */ @@ -148,18 +157,19 @@ export type RuntimePtyController = { ): WriteSettlement | Promise /** Attach existing local daemon output without creating, resizing or focusing; refuse uncertain, SSH-scoped or non-daemon sessions. */ attach?(ptyId: string): Promise + killFenced?: (request: PtyFencedTerminationRequest) => Promise kill(ptyId: string): boolean /** `incarnationId` is the rejected spawn's; a same-id respawn since then is left alone. */ retireRejectedPty?(ptyId: string, stopConfirmed: boolean, incarnationId: string | undefined): void stopAndWait?( ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: { keepHistory?: boolean; deadlineMs?: number; expectedIncarnationId?: string } ): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise inspectProcess?( ptyId: string, - options?: { expectedIncarnationId?: PtyIncarnationId; scanChildProcesses?: boolean } + options?: PtyProcessInspectionOptions ): Promise confirmForegroundProcess?(ptyId: string): Promise confirmShellForeground?(ptyId: string): Promise diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e6cdede25e0d..639fd0e621a8 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -27,6 +27,7 @@ import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from '../../shared/worker-terminal-host-scope' +import type { AgentSessionSwitchAuthority } from '../../shared/agent-session-switch-execution' export type TerminalCreateOptions = { command?: string @@ -41,7 +42,7 @@ export type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupAgent?: TuiAgent @@ -106,11 +107,15 @@ export type TerminalCreateOptions = { /** The user's own act (a CLI command, a paired client's create, a launch); without it a stopped WSL distro holds the start. */ wslUserAct?: true signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority /** Refuse (`worktree_terminals_sleeping`) instead of waking a worktree the host has put to sleep. */ refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ + beforePtySpawn?: () => void onPtySpawnDispatched?: (launch?: { + tabId: string + leafId: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent }) => void diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index 3c5cc848d63c..67b6e23fde5d 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -149,7 +149,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { summary.hasAttachedPty = true summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) - const ptyStatus = getSavedTabWorktreeStatus(owner.title, true) + const ptyStatus = getSavedTabWorktreeStatus(displayTitle ?? owner.title, true) if (ptyStatus === 'working' || ptyStatus === 'permission') { addTerminalTitleEvidence(titleEvidence, summary.worktreeId, { status: ptyStatus, diff --git a/src/main/runtime/session-graph/session-window-graph-leaf.ts b/src/main/runtime/session-graph/session-window-graph-leaf.ts new file mode 100644 index 000000000000..0681fac23c24 --- /dev/null +++ b/src/main/runtime/session-graph/session-window-graph-leaf.ts @@ -0,0 +1,42 @@ +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-session-contracts' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' + +export function runtimeGraphLeaf( + leaf: RuntimeSyncWindowGraph['leaves'][number], + existing: RuntimeLeafRecord | undefined, + ptyId: string | null, + ptyGeneration: number, + connected: boolean, + graphStatus: string, + tailSource: Partial | undefined, + graphSyncedAt: number +): RuntimeLeafRecord { + return { + ...leaf, + ptyId, + ptyGeneration, + connected, + writable: graphStatus === 'ready' && connected, + lastOutputAt: tailSource?.lastOutputAt ?? null, + lastExitCode: tailSource?.lastExitCode ?? null, + lastExitCause: tailSource?.lastExitCause ?? null, + tailBuffer: tailSource?.tailBuffer ?? [], + tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], + tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, + tailPartialLine: tailSource?.tailPartialLine ?? '', + tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', + tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, + tailTruncated: tailSource?.tailTruncated ?? false, + tailLinesTotal: tailSource?.tailLinesTotal ?? 0, + preview: tailSource?.preview ?? '', + waitBlockedAt: tailSource?.waitBlockedAt ?? null, + lastAgentStatus: tailSource?.lastAgentStatus ?? null, + lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, + lastOscTitle: tailSource?.lastOscTitle ?? null, + lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, + paneTitleUpdatedAt: + existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle + ? existing.paneTitleUpdatedAt + : graphSyncedAt + } +} diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 6679260704f3..907bebf5dc81 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -7,8 +7,8 @@ import type { } from '../../../shared/runtime-session-contracts' import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' import type { RuntimeLeafRecord } from '../runtime-terminal-state-records' - import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { runtimeGraphLeaf } from './session-window-graph-leaf' type Graph = RuntimeSyncWindowGraph @@ -24,6 +24,16 @@ function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void } export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow { + private switchProjectionViewer: { windowId: number; generation: string | undefined } | undefined + + supportsConditionalSwitchProjection(): boolean { + return ( + this.graphStatus === 'ready' && + this.switchProjectionViewer?.windowId === this.authoritativeWindowId && + this.switchProjectionViewer?.generation === this.rendererGeneration + ) + } + /** Layout-owner builds: main's panes in place of the window's (session-tabs-inventory.ts). */ protected abstract withMainSurfaces(windowId: number, graph: Graph): Graph declare protected terminalGroupGridSizes?: RuntimeSyncWindowGraph['terminalGroupGridSizes'] @@ -86,6 +96,10 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta this.servesHeadlessGraph = true this.rendererGeneration = null } + this.switchProjectionViewer = + graph.conditionalSwitchProjection === 1 + ? { windowId, generation: rendererGeneration ?? undefined } + : undefined graph = ORCA_LAYOUT_OWNER_IS_MAIN ? this.withMainSurfaces(windowId, graph) : graph if (graph.terminalGroupGridSizes) { @@ -148,34 +162,19 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta const connected = this.graphLeafConnected(ptyId, leaf.worktreeId) const tailSource = existing?.ptyId === ptyId ? existing : existingPty - nextLeaves.set(leafKey, { - ...leaf, - ptyId, - ptyGeneration, - connected, - writable: this.graphStatus === 'ready' && connected, - lastOutputAt: tailSource?.lastOutputAt ?? null, - lastExitCode: tailSource?.lastExitCode ?? null, - lastExitCause: tailSource?.lastExitCause ?? null, - tailBuffer: tailSource?.tailBuffer ?? [], - tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], - tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, - tailPartialLine: tailSource?.tailPartialLine ?? '', - tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', - tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, - tailTruncated: tailSource?.tailTruncated ?? false, - tailLinesTotal: tailSource?.tailLinesTotal ?? 0, - preview: tailSource?.preview ?? '', - waitBlockedAt: tailSource?.waitBlockedAt ?? null, - lastAgentStatus: tailSource?.lastAgentStatus ?? null, - lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, - lastOscTitle: tailSource?.lastOscTitle ?? null, - lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, - paneTitleUpdatedAt: - existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle - ? existing.paneTitleUpdatedAt - : graphSyncedAt - }) + nextLeaves.set( + leafKey, + runtimeGraphLeaf( + leaf, + existing, + ptyId, + ptyGeneration, + connected, + this.graphStatus, + tailSource, + graphSyncedAt + ) + ) // Main-owned pane presence does not prove its terminal is connected. if (leaf.ptyId && connected && !this.mainDecidesLiveness(leaf.worktreeId)) { @@ -194,7 +193,7 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta const adoptedFirstPty = existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) if (!adoptedFirstPty) { - this.invalidateLeafHandle(leafKey) + this.invalidateLeafHandle(leafKey, ptyId === null) } } } diff --git a/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts b/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts index 307a2eafd6ca..cb7434269d46 100644 --- a/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts +++ b/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts @@ -1,4 +1,6 @@ -// In main-owned layouts, phone row closes use main's pane/tab close with or without a window. +import type { MobileSessionLifecycleCloseHost } from '../../mobile-session-lifecycle-close-adjudication' +// Layout-owner builds: a phone's close of a terminal row in a workspace main owns is main's own +// layout close, window or not, as the CLI's close by handle is. import { OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose } from './refuse-unattributed-mobile-session-tab-close' import type { RuntimeMobileSessionTerminalTab, @@ -86,4 +88,14 @@ export abstract class OrcaRuntimeWithCloseMobileRowInMainLayout extends OrcaRunt this.notifyMobileSessionTabsChanged(worktreeId) } } + protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost { + return { + tabs: this.tabs, + leaves: this.leaves, + ptysById: this.ptysById, + findPtyForMobileTerminalTab: (worktreeId, tab) => + this.findPtyForMobileTerminalTab(worktreeId, tab), + republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId) + } + } } diff --git a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts index d8b902b22ff1..2acf24acdde1 100644 --- a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts +++ b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts @@ -1,15 +1,13 @@ import { OrcaRuntimeWithCloseMobileRowInMainLayout } from './close-mobile-row-in-main-layout' -import type { - RuntimeMobileSessionTerminalTab, - RuntimeSessionTabCloseReason -} from '../../../../shared/runtime-types' -import type { RuntimePtyTabCloseAuthority } from '../../runtime-terminal-state-records' +import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' import { adjudicateAbsentMobileSessionTabClose, - resolveMobileSessionLifecycleCloseContext, - type MobileSessionLifecycleCloseHost + resolveMobileSessionLifecycleCloseContext } from '../../mobile-session-lifecycle-close-adjudication' -import type { MobileSessionTabCloseOutcome } from '../../mobile-session-tab-close-outcome' +import type { + MobileSessionTabCloseOptions, + MobileSessionTabCloseOutcome +} from '../../mobile-session-tab-close-outcome' import { committedMobileSessionTabClose, delegatedMobileSessionTabClose, @@ -29,20 +27,32 @@ export abstract class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWi async closeMobileSessionTab( worktreeSelector: string, tabId: string, - options: { - reason?: RuntimeSessionTabCloseReason - expectedPublicationEpoch?: string - expectedTerminalHandle?: string - clientNavigationId?: string - localPtyTeardownOwnedExternally?: boolean - expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority - force?: boolean - } = {} + options: MobileSessionTabCloseOptions = {} ): Promise { const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id + if (options.originalPresentation) { + const source = options.originalPresentation.identity + if ( + source.worktreeId !== worktreeId || + source.tabId !== tabId || + !options.localPtyTeardownOwnedExternally + ) { + throw new Error('terminal_pane_owner_changed') + } + const closedTab = await this.closeTerminalPane( + worktreeId, + { kind: 'pane', tabId, leafId: source.leafId }, + options + ) + return committedMobileSessionTabClose( + this.clientSessionTabSelections, + worktreeId, + closedTab ? [tabId] : [] + ) + } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() if (graphEpoch !== null) { @@ -306,15 +316,4 @@ export abstract class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWi } return finishCommittedClose() } - - protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost { - return { - tabs: this.tabs, - leaves: this.leaves, - ptysById: this.ptysById, - findPtyForMobileTerminalTab: (worktreeId, tab) => - this.findPtyForMobileTerminalTab(worktreeId, tab), - republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId) - } - } } diff --git a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts index 75121b8b84e9..4e6b5d5ed93f 100644 --- a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts @@ -1,5 +1,8 @@ +import { windowListsTab } from '../../terminal/surface/main-terminal-surfaces' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { matchesTerminalSurfaceCapture } from '../../../../shared/terminal-tab-close' +import { OrcaRuntimeWithTerminalSurfaceCloseCommit } from '../../session-tabs/terminal-surface-close-commit' import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as BrowserOwnership } from '../../session-tabs/mobile-session-publication-preservation' -import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from '../../session-tabs/terminal-surface-retirements' import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsResult, @@ -10,29 +13,28 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' import { holdAgentSessionInventory } from '../../structured-agent-session-inventory-hold' import type { Tab } from '../../../../shared/tab-types' -import { - captureTerminalCloseRequest, - resolveTerminalCloseTarget, - type PaneCloseResolution, - type RendererTerminalClose, - type TerminalSurfaceCloseOptions -} from '../../terminal-surface-close' -import type { - TerminalPaneCloseTarget, - TerminalSurfaceCloseTarget -} from '../../../../shared/terminal-surface-close-target' +import type { TerminalPaneCloseTarget } from '../../../../shared/terminal-surface-close-target' import { retireTerminalSurfacesFromSnapshot } from '../../mobile-session-terminal-retirement' import type { PtyControllerInventory } from '../../runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { captureAcknowledgedTerminalTabRetirement } from '../../workspace-session-terminal-tab-retirement-identity' -import { closeLeafOrTab } from '../../../persistence/terminal-topology/terminal-topology-commit' import { markAgentLaunchesClosedByUser } from '../../../agent-launch/agent-launch-pane-attachment' -export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { +import type { MobileSessionTabCloseOptions } from '../../mobile-session-tab-close-outcome' + +type TerminalPaneRetirementOptions = Pick< + MobileSessionTabCloseOptions, + 'originalPresentation' | 'rendererOwnsPresentation' | 'retirementProofs' +> + +export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithTerminalSurfaceCloseCommit { protected abstract getRendererOwnedBrowserPageIds( ...args: Parameters ): ReturnType - // Offscreen browser pages live outside renderer graphs; expose session rows or snapshots prune them and close fails with tab_not_found. + // Why: headless serve backs browser panes with offscreen WebContents that live + // only in the BrowserManager, never in a renderer graph. Without surfacing them + // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the + // paired browser tab and closing it fails with tab_not_found. Synthesize browser + // session tabs from the live bridge so they are first-class alongside terminals. protected buildHeadlessMobileSessionBrowserTabs( worktreeId: string ): RuntimeMobileSessionBrowserTab[] { @@ -103,120 +105,87 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten return tab ? { color: tab.color, isPinned: tab.isPinned } : null } - protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { - const originalHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) - return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { - const resolvedHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) - const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) - // Emptying the last tab may reroute the worktree to its catalog host. - const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) - ? resolvedHostId - : originalHostId - return { - hostId, - session: this.store?.getWorkspaceSession?.(hostId) ?? null, - snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), - incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId - } - }) - } - - /** Commit explicit-close membership removal durably in the owning host partition before callers publish and kill. */ - protected async closeTerminalSurface( + /** + * Commits a split pane's close that main started, then tells the desktop renderer to drop that + * pane. Never touches the tab: a pane the session no longer lists commits nothing. + */ + protected async closeTerminalPane( worktreeId: string, - target: TerminalSurfaceCloseTarget, - options: TerminalSurfaceCloseOptions = {} - ): Promise { - const store = this.store - if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { - throw new Error('workspace_session_unavailable') + target: TerminalPaneCloseTarget, + options: TerminalPaneRetirementOptions = {} + ): Promise { + let closedTab = false + if ( + options.originalPresentation && + !this.originalTerminalExecutionMatches(options.originalPresentation.identity) + ) { + throw new Error('terminal_pane_owner_changed') } - // Already-removed layout tabs have no newer owner to protect; refusing would strand them. - const acknowledgeTabRetirement = - target.kind === 'tab' && !options.closedByLayoutOwner - ? this.captureTerminalTabRetirement(worktreeId, target.tabId) - : null - let ptyIdsToKill: string[] = [] - let refusal: Error | undefined - const hostIds = () => this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId) - const fenced = acknowledgeTabRetirement !== null - try { - refusal = await store.runDurableMutation( - closeLeafOrTab({ - worktreeId, - target, - options, - ...captureTerminalCloseRequest( - worktreeId, - target, - hostIds(), - { getWorkspaceSession: (hostId) => store.getWorkspaceSession!(hostId) }, - fenced - ), - ownerMatches: () => !acknowledgeTabRetirement || acknowledgeTabRetirement().matches, - hostIds, - getSession: (hostId) => store.getWorkspaceSession!(hostId), - setSession: (session, hostId) => store.setWorkspaceSession!(session, hostId), - onClosed: (closedPtyIds) => { - ptyIdsToKill = closedPtyIds - } - }) - ) - } catch (error) { - console.error('[runtime] failed to persist terminal close:', error) - } - if (refusal) { - throw refusal - } - return ptyIdsToKill - } - - /** The desktop renderer's close intent: it already guarded, removed and killed; this only reports. */ - async closeTerminalSurfaceFromRenderer({ worktreeId, target, reason }: RendererTerminalClose) { - const options = { allowMissing: true, force: true, closedByLayoutOwner: true, reason } - await this.closeTerminalSurface(worktreeId, target, options) - } - - /** Resolves a close main started against the copy of the tab's panes its layout owner holds. */ - protected resolveTerminalCloseTarget( - worktreeId: string, - target: TerminalSurfaceCloseTarget - ): PaneCloseResolution | 'tab' { - const graphLeafIds: string[] = [] - for (const leaf of this.leaves.values()) { - if (leaf.tabId === target.tabId) { - graphLeafIds.push(leaf.leafId) + if ( + options.originalPresentation && + (options.rendererOwnsPresentation || windowListsTab(this, this.tabs, target.tabId)) + ) { + if (!this.notifier?.closeTerminalTab) { + throw new Error('runtime_unavailable') } + await this.notifier.closeTerminalTab(target.tabId, { + localPtyTeardownOwnedExternally: true, + originalSurface: options.originalPresentation.identity + }) } - return resolveTerminalCloseTarget(target, { - rendererListsTab: this.tabs.has(target.tabId), - snapshotRows: (this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs ?? []).filter( - (row): row is RuntimeMobileSessionTerminalTab => - row.type === 'terminal' && row.parentTabId === target.tabId - ), - graphLeafIds, - sessionLayout: - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId?.[target.tabId] - }) - } - - /** Commit only this pane's membership removal, then notify the renderer; absent panes commit nothing and the tab is untouched. */ - protected async closeTerminalPane( - worktreeId: string, - target: TerminalPaneCloseTarget - ): Promise { // An explicit close (a phone's, the CLI's): a launch still starting or delivering there stops. - markAgentLaunchesClosedByUser(worktreeId, target) + if (!options.originalPresentation) { + markAgentLaunchesClosedByUser(worktreeId, target) + } try { - await this.closeTerminalSurface(worktreeId, target, { allowMissing: true }) + if (ORCA_LAYOUT_OWNER_IS_MAIN && options.originalPresentation) { + const reply = await this.applyLayoutCommand( + { + type: 'closePane', + workspace: worktreeId, + tabId: target.tabId, + leafId: target.leafId + }, + { + originalPresentation: options.originalPresentation, + admit: () => + this.originalTerminalExecutionMatches(options.originalPresentation!.identity) + } + ) + if (!reply.ok) { + throw new Error(reply.code) + } + closedTab = reply.result.closed?.includes(target.tabId) === true + } else { + await this.closeTerminalSurface(worktreeId, target, { + allowMissing: true, + ...options, + onClosed: (tab) => { + closedTab = tab + } + }) + } } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + if ( + options.originalPresentation || + !(error instanceof Error) || + error.message !== 'workspace_session_unavailable' + ) { throw error } } - // Exit may never arrive; leaf-addressed retirement and renderer exit handling are mutually idempotent. - this.retireClosedTerminalLeafFromMobileSnapshot(worktreeId, target.tabId, target.leafId) - this.notifier?.closeTerminalPane?.(target.tabId, target.leafId) + // Why: no exit may ever arrive to remove the pane. The notice is leaf-addressed, so it and the + // renderer's exit handling are each a no-op after the other. + this.retireClosedTerminalLeafFromMobileSnapshot( + worktreeId, + target.tabId, + target.leafId, + options + ) + if (!options.originalPresentation) { + this.notifier?.closeTerminalPane?.(target.tabId, target.leafId) + } + return closedTab } /** A paired client's close of one pane: stops only that pane's process, commits only that pane. */ @@ -242,7 +211,8 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten private retireClosedTerminalLeafFromMobileSnapshot( worktreeId: string, tabId: string, - leafId: string + leafId: string, + options: TerminalPaneRetirementOptions = {} ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( @@ -251,17 +221,47 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten candidate.parentTabId === tabId && candidate.leafId === leafId ) - // Why: a renderer that lists the tab republishes its own snapshot once it drops the pane. - if (!snapshot || !tab || this.tabs.has(tabId)) { + if ( + options.originalPresentation && + (!this.originalTerminalExecutionMatches(options.originalPresentation.identity) || + !matchesTerminalSurfaceCapture( + options.originalPresentation, + this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId), + (hostId) => this.store?.getWorkspaceSession?.(hostId) + )) + ) { + throw new Error('terminal_pane_owner_changed') + } + const original = options.originalPresentation?.identity + const bound = tab?.ptyId ?? tab?.parentLayout?.ptyIdsByLeafId?.[leafId] + if ( + original && + ((bound && bound !== original.ptyId) || + (tab?.incarnationId && tab.incarnationId !== original.incarnationId)) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (original?.ptyId) { + const pty = this.ptysById.get(original.ptyId) + if (pty) { + pty.runtimeSessionOwned = false + } + this.setPairedRendererSessionOwnership(original.ptyId, false) + } + // A renderer can publish removal while its required durable close is awaiting completion. + if (!snapshot || !tab || (!original && this.tabs.has(tabId))) { + if (original) { + this.publishRetiredTerminalSurfaceProofs(worktreeId, options.retirementProofs ?? []) + } return } - const proof = this.getMobileSessionTerminalRetirementProof(worktreeId, tab) + const proof = !original && this.getMobileSessionTerminalRetirementProof(worktreeId, tab) const retired = retireTerminalSurfacesFromSnapshot({ snapshot, - ptyId: tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[leafId] ?? '', + ptyId: original?.ptyId ?? tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[leafId] ?? '', exactSurfaces: [{ parentTabId: tabId, leafId }], exactOnly: true, - ...(proof ? { retirementProofs: [proof] } : {}) + retirementProofs: options.retirementProofs ?? (proof ? [proof] : []) }) if (retired) { this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) diff --git a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts index 59c6f03f1c64..b27d01e53911 100644 --- a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts @@ -129,6 +129,9 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten ): Promise { const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') const targetsHost = navigationTargetsHost(navigation) + if (targetsHost && !isAutomaticTabActivation(opts.intent)) { + this.clientSessionTabSelections.noteHostNavigation() + } const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) let worktreeId = explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id diff --git a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts index 43f1e844394b..699e691f6e24 100644 --- a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts @@ -1,3 +1,4 @@ +import { pruneGroupLayout } from '../../../../shared/workspace-session-terminal-tab-close' import { selectFreshAgentRowForMobileTab } from '../../runtime-hook-agent-row-selection' import { OrcaRuntimeWithScheduleMobileSessionTabsChanged } from '../../session-tabs/session-tabs-change-publication' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' @@ -14,11 +15,7 @@ import { projectRuntimeMobileSessionTabs } from '../../runtime-mobile-session-pr import type { RuntimeMobileSessionProjectionHost } from '../../runtime-mobile-session-projection-contract' import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' import type { RuntimeAgentRowSnapshot } from '../../runtime-worktree-agent-rows' -import type { - AgentStatusEntry, - AgentStatusIpcPayload, - AgentStatusOrchestrationContext -} from '../../../../shared/agent-status-types' +import type * as AgentStatus from '../../../../shared/agent-status-types' import { buildRuntimeMobileAgentStatus } from '../../runtime-mobile-agent-status-builder' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' @@ -51,18 +48,7 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or layout: TabGroupLayoutNode | null | undefined, validGroupIds: ReadonlySet ): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return validGroupIds.has(layout.groupId) ? layout : null - } - const first = this.pruneMobileSessionTabGroupLayout(layout.first, validGroupIds) - const second = this.pruneMobileSessionTabGroupLayout(layout.second, validGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second + return pruneGroupLayout(layout ?? undefined, validGroupIds) ?? null } protected mergeMobileSessionTabGroups( @@ -160,9 +146,9 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null, retained: RuntimeAgentRowSnapshot | null, - getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] - ): { agentStatus: AgentStatusEntry } | Record { - // Phone status uses display records to include stale-working title clears. + getHookRowsForPane: (paneKey: string) => AgentStatus.AgentStatusIpcPayload[] + ): { agentStatus: AgentStatus.AgentStatusEntry } | Record { + // Why display records: a phone status is presentation, so it shows the stale-working clear. const displayPty = pty ? this.getPtyDisplayRecord(pty) : null return buildRuntimeMobileAgentStatus( displayPty, @@ -188,7 +174,7 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or paneKey: string, pty: RuntimePtyWorktreeRecord | null, _tab: RuntimeMobileSessionTerminalTab, - getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatus.AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null { const paneMatch = selectFreshAgentRowForMobileTab({ paneKey, @@ -297,7 +283,7 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or getAgentStatusOrchestrationContextForPaneKey( paneKey: string - ): AgentStatusOrchestrationContext | undefined { + ): AgentStatus.AgentStatusOrchestrationContext | undefined { const handle = this.getTerminalHandleForPaneKey(paneKey) return handle ? this.agentOrchestrationProjection.getForHandle(handle, undefined, { paneKey }) @@ -321,6 +307,11 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or if (leaf?.ptyId) { return this.issueHandle(leaf) } - return panePty ? this.issuePtyHandle(panePty) : null + if (panePty) { + return this.issuePtyHandle(panePty) + } + const ended = leaf ? this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) : null + const ptyId = ended ? this.handles.get(ended)?.ptyId : null + return ptyId && this.getPtyLivenessVerdict(ptyId)?.status === 'exited' ? (ended ?? null) : null } } diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts index 0a9bc09a4216..f0fa321efa6c 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts @@ -1,3 +1,4 @@ +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import { OrcaRuntimeWithFocusTerminal } from '../terminal/focus/focus-terminal' import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from '../orca-runtime-core' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../shared/pty-liveness-verdict' @@ -11,46 +12,70 @@ export const NO_STOP: ExplicitCloseStop = { stopped: false } export abstract class OrcaRuntimeWithExplicitCloseTabPtyStops extends OrcaRuntimeWithFocusTerminal { protected async stopExplicitlyClosedTabPtys( ptyIds: readonly string[], - addressedPtyId: string + addressedPtyId: string, + capturedIncarnationId?: string ): Promise { let addressedPtyStop = NO_STOP const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS for (const ptyId of ptyIds) { this.markPtyStopRequested(ptyId) - const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId - let stopped = false - if (this.ptyController?.stopAndWait) { - try { - stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs }) - } catch (error) { - this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) - } - // Preserve an observed exit when a broader inventory check could not finish. - if ( - !stopped && - expectedIncarnationId && - this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && - this.getPtyLivenessVerdict(ptyId)?.status === 'exited' - ) { - stopped = true - } - if (!stopped) { - const verdict = this.getPtyLivenessVerdict(ptyId) - const providerAlreadyRetiredPty = - verdict?.status === 'unverifiable' && - verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON - if (!providerAlreadyRetiredPty) { - this.ptyController.kill(ptyId) - if (!verdict || verdict.status === 'live') { - this.markPtyLivenessUnverifiable( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) + const expectedIncarnationId = capturedIncarnationId ?? this.ptysById.get(ptyId)?.incarnationId + const current = this.ptysById.get(ptyId) + const replacement = this.pendingPtyHandleReplacementFences.get(ptyId) + let stopped = + !!capturedIncarnationId && + (!current || current.incarnationId === capturedIncarnationId) && + (!replacement || replacement.incarnationId === capturedIncarnationId) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + const settle = capturedIncarnationId + ? this.intentionalPtyStops.mark(ptyId, 'closing', capturedIncarnationId) + : null + try { + if (!stopped && this.ptyController?.stopAndWait) { + try { + stopped = await this.ptyController.stopAndWait(ptyId, { + deadlineMs, + ...(capturedIncarnationId ? { expectedIncarnationId: capturedIncarnationId } : {}) + }) + } catch (error) { + this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) + } + // Preserve an observed exit when a broader inventory check could not finish. + if ( + !stopped && + expectedIncarnationId && + (!this.ptysById.has(ptyId) || + this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId) && + (!this.pendingPtyHandleReplacementFences.has(ptyId) || + this.pendingPtyHandleReplacementFences.get(ptyId)?.incarnationId === + expectedIncarnationId) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + ) { + stopped = true + } + if (!stopped && !capturedIncarnationId) { + const verdict = this.getPtyLivenessVerdict(ptyId) + const providerAlreadyRetiredPty = + verdict?.status === 'unverifiable' && + verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON + if (!providerAlreadyRetiredPty) { + this.ptyController.kill(ptyId) + if (!verdict || verdict.status === 'live') { + this.markPtyLivenessUnverifiable( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + } } } + } else if (!capturedIncarnationId) { + stopped = this.ptyController?.kill(ptyId) ?? false + } + } finally { + settle?.(stopped) + if (capturedIncarnationId && !stopped) { + this.clearPtyStopRequest(ptyId) } - } else { - stopped = this.ptyController?.kill(ptyId) ?? false } if (ptyId === addressedPtyId) { addressedPtyStop = { stopped } @@ -58,4 +83,24 @@ export abstract class OrcaRuntimeWithExplicitCloseTabPtyStops extends OrcaRuntim } return addressedPtyStop } + + protected describeTerminalClose( + handle: string, + tabId: string, + ptyId: string | null, + stop: ExplicitCloseStop + ): RuntimeTerminalClose { + const close: RuntimeTerminalClose = { handle, tabId, ptyKilled: stop.stopped } + if (stop.stopped || !ptyId) { + return close + } + const verdict = this.getPtyLivenessVerdict(ptyId) + if (verdict?.status === 'unverifiable') { + return { ...close, ptyStopVerdict: 'unverifiable', ptyStopReason: verdict.reason } + } + if (verdict?.status === 'live') { + return { ...close, ptyStopVerdict: 'live' } + } + return close + } } diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index 2269cf45bb63..197d662ff346 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -1,3 +1,6 @@ +import { OrcaRuntimeWithTerminalSwitchCloseCapture } from './terminal-switch-close-capture' +import { NO_STOP } from './explicit-terminal-close-pty-stops' +import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' import type { WorktreePtyHostFence } from '../worktree-pty-host-fence' import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' @@ -8,16 +11,11 @@ import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspac import type { WindowLayoutCommand } from '../runtime-layout-apply' import { closeMainOwnedPaneByHandle } from './main-handle-close' import { - NO_STOP, - OrcaRuntimeWithExplicitCloseTabPtyStops, - type ExplicitCloseStop -} from './explicit-terminal-close-pty-stops' -import { - closePairedHostLeafFirst, + closeTerminalOnPairedHost, + getPairedHostTerminal, isTerminalTabPinnedInSession } from './paired-host-terminal-close' - -export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithExplicitCloseTabPtyStops { +export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithTerminalSwitchCloseCapture { protected abstract getResolvedWorktreeMap(): Promise> abstract applyLayoutCommand(command: WindowLayoutCommand): Promise protected abstract refreshPtyWorktreeRecordsFromController( @@ -44,46 +42,69 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun terminalHandlesByPtyId: Readonly> }): void - protected describeTerminalClose( + async closeTerminal( handle: string, - tabId: string, - ptyId: string | null, - stop: ExplicitCloseStop - ): RuntimeTerminalClose { - const close: RuntimeTerminalClose = { handle, tabId, ptyKilled: stop.stopped } - if (stop.stopped || !ptyId) { - return close - } - const verdict = this.getPtyLivenessVerdict(ptyId) - if (verdict?.status === 'unverifiable') { - return { ...close, ptyStopVerdict: 'unverifiable', ptyStopReason: verdict.reason } - } - if (verdict?.status === 'live') { - return { ...close, ptyStopVerdict: 'live' } + incarnation?: string, + originalPresentation?: TerminalSurfaceCloseCapture + ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (originalPresentation) { + const remote = getPairedHostTerminal(pty?.pty.ptyId ?? this.handles.get(handle)?.ptyId) + if (remote) { + const leaf = this.getLiveLeafForHandle(handle).leaf + const pane = { kind: 'pane' as const, tabId: leaf.tabId, leafId: leaf.leafId } + if ( + this.resolveTerminalCloseTarget(leaf.worktreeId, pane) === 'last-pane' && + this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((id) => + isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(id), leaf) + ) + ) { + throw new Error('terminal_tab_pinned') + } + const close = await closeTerminalOnPairedHost( + (...args) => this.callOrchestrationWorkerServer(...args), + remote, + 'terminal.close', + { expectedIncarnationId: incarnation, originalPresentation } + ) + return { handle, tabId: leaf.tabId, ...close } + } + const captured = this.captureTerminalSwitchClose( + handle, + incarnation ?? '', + originalPresentation, + false + ) + const target = { + kind: 'pane' as const, + tabId: originalPresentation.identity.tabId, + leafId: originalPresentation.identity.leafId + } + if ( + this.resolveTerminalCloseTarget(originalPresentation.identity.worktreeId, target) === + 'last-pane' && + this.getWorkspaceSessionHostIdsForTab( + originalPresentation.identity.worktreeId, + target.tabId + ).some((id) => + isTerminalTabPinnedInSession( + this.store?.getWorkspaceSession?.(id), + originalPresentation.identity + ) + ) + ) { + throw new Error('terminal_tab_pinned') + } + await captured.stop() + return captured.retire() } - return close - } - /** null when this machine owns the PTY; see `closePairedHostLeafFirst` otherwise. */ - private closeOnOwningPairedHost( - handle: string, - leaf: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, - method: 'terminal.close' | 'terminal.closeTab', - closesTab: boolean - ): ReturnType { - return closePairedHostLeafFirst(leaf, method, { - call: (environmentId, hostMethod, params, timeoutMs) => - this.callOrchestrationWorkerServer(environmentId, hostMethod, params, timeoutMs), - isTabPinned: () => - closesTab && - this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((hostId) => - isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(hostId), leaf) - ), - readBoundLeaf: () => this.getLiveLeafForHandle(handle).leaf - }) - } - - async closeTerminal(handle: string): Promise { + if (incarnation && pty?.pty.incarnationId !== incarnation) { + throw new Error('terminal_handle_stale') + } + const stopCurrent = (ids: readonly string[], addressed: string) => + this.stopExplicitlyClosedTabPtys(ids, addressed) + // Switch point: a pane main owns closes in main's layout, whatever the window lists. if (ORCA_LAYOUT_OWNER_IS_MAIN) { const mainClose = await closeMainOwnedPaneByHandle( { @@ -93,7 +114,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun remoteSessionIdOf: (worktreeId, tabId) => this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], applyLayoutCommand: (command) => this.applyLayoutCommand(command), - stop: (ptyIds, addressed) => this.stopExplicitlyClosedTabPtys(ptyIds, addressed), + stop: stopCurrent, describe: (...args) => this.describeTerminalClose(...args) }, handle, @@ -104,7 +125,6 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun return mainClose } } - const pty = this.getLivePtyForHandle(handle) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { const closeAuthority: RuntimePtyTabCloseAuthority = { @@ -143,7 +163,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } this.notifyRendererOfHeadlessTerminalClose(tabId) } - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } if (closesTab && surface && ptyCloseAuthority && !this.tabs.has(surface.tab.parentTabId)) { @@ -157,11 +177,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) this.notifyRendererOfHeadlessTerminalClose(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } // Headless hosts have no renderer tab to close through the notifier. @@ -184,11 +204,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun tabClosed = false } if (tabClosed) { - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { // Commit this pane's removal explicitly; unconfirmed stop never authorizes closing live siblings. if (paneTarget) { @@ -240,9 +260,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } return { handle, tabId: leaf.tabId, ...hostClose.close } } - const stop = leaf.ptyId - ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId) - : NO_STOP + const stop = leaf.ptyId ? await stopCurrent(ptyIdsToKill, leaf.ptyId) : NO_STOP if (!closesTab) { await this.closeTerminalPane(leaf.worktreeId, paneTarget) } else if (!this.notifier?.closeTerminalTab) { @@ -250,58 +268,4 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, stop) } - - async closeTerminalTab(handle: string): Promise { - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - const mainClose = await closeMainOwnedPaneByHandle( - { - runtime: this, - livePty: () => this.getLivePtyForHandle(handle)?.pty ?? null, - liveLeaf: () => this.getLiveLeafForHandle(handle).leaf, - remoteSessionIdOf: (worktreeId, tabId) => - this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], - applyLayoutCommand: (command) => this.applyLayoutCommand(command), - stop: (ptyIds, addressed) => this.stopExplicitlyClosedTabPtys(ptyIds, addressed), - describe: (...args) => this.describeTerminalClose(...args) - }, - handle, - true - ) - if (mainClose) { - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return mainClose - } - } - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const closeAuthority: RuntimePtyTabCloseAuthority = { - handle, - ptyId: pty.pty.ptyId, - incarnationId: pty.pty.incarnationId, - worktreeId: pty.pty.worktreeId - } - const tabId = - this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? - this.paneOfPty(pty.pty).tabId - if (!tabId) { - return this.closeTerminal(handle) - } - // Handle-addressed CLI/automation close is user intent under lifecycle-echo adjudication. - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - reason: 'user', - expectedPtyCloseAuthority: closeAuthority - }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId, closeMode: 'tab', ptyKilled: false } - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - const hostClose = await this.closeOnOwningPairedHost(handle, leaf, 'terminal.closeTab', true) - if (hostClose && !hostClose.stillBound) { - return { handle, tabId: leaf.tabId, ...hostClose.close, closeMode: 'tab' } - } - await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId: leaf.tabId, ptyKilled: false, ...hostClose?.close, closeMode: 'tab' } - } } diff --git a/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts b/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts new file mode 100644 index 000000000000..57485175532d --- /dev/null +++ b/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts @@ -0,0 +1,90 @@ +import { OrcaRuntimeWithExplicitCloseTabPtyStops } from './explicit-terminal-close-pty-stops' +export { NO_STOP } from './explicit-terminal-close-pty-stops' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { closeMainOwnedPaneByHandle } from './main-handle-close' +import { + closePairedHostLeafFirst, + isTerminalTabPinnedInSession +} from './paired-host-terminal-close' + +export abstract class OrcaRuntimeWithExplicitTerminalTabClose extends OrcaRuntimeWithExplicitCloseTabPtyStops { + abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + /** null when this machine owns the PTY; see `closePairedHostLeafFirst` otherwise. */ + protected closeOnOwningPairedHost( + handle: string, + leaf: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, + method: 'terminal.close' | 'terminal.closeTab', + closesTab: boolean + ): ReturnType { + return closePairedHostLeafFirst(leaf, method, { + call: (environmentId, hostMethod, params, timeoutMs) => + this.callOrchestrationWorkerServer(environmentId, hostMethod, params, timeoutMs), + isTabPinned: () => + closesTab && + this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((hostId) => + isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(hostId), leaf) + ), + readBoundLeaf: () => this.getLiveLeafForHandle(handle).leaf + }) + } + + async closeTerminalTab(handle: string): Promise { + // Switch point: a pane main owns closes in main's layout, whatever the window lists. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const mainClose = await closeMainOwnedPaneByHandle( + { + runtime: this, + livePty: () => this.getLivePtyForHandle(handle)?.pty ?? null, + liveLeaf: () => this.getLiveLeafForHandle(handle).leaf, + remoteSessionIdOf: (worktreeId, tabId) => + this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], + applyLayoutCommand: (command) => this.applyLayoutCommand(command), + stop: (ptyIds, addressed) => this.stopExplicitlyClosedTabPtys(ptyIds, addressed), + describe: (...args) => this.describeTerminalClose(...args) + }, + handle, + true + ) + if (mainClose) { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return mainClose + } + } + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const closeAuthority: RuntimePtyTabCloseAuthority = { + handle, + ptyId: pty.pty.ptyId, + incarnationId: pty.pty.incarnationId, + worktreeId: pty.pty.worktreeId + } + const tabId = + this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? + this.paneOfPty(pty.pty).tabId + if (!tabId) { + return this.closeTerminal(handle) + } + // Why: a handle-addressed CLI/automation close is an explicit intent, so + // it must stay destructive under the non-user close adjudication gate. + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + reason: 'user', + expectedPtyCloseAuthority: closeAuthority + }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + const hostClose = await this.closeOnOwningPairedHost(handle, leaf, 'terminal.closeTab', true) + if (hostClose && !hostClose.stillBound) { + return { handle, tabId: leaf.tabId, ...hostClose.close, closeMode: 'tab' } + } + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, ptyKilled: false, ...hostClose?.close, closeMode: 'tab' } + } +} diff --git a/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts b/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts index 10076d2a96fc..8fd75883cb9a 100644 --- a/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts +++ b/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts @@ -27,6 +27,7 @@ export abstract class OrcaRuntimeWithHeadlessPaneMoveToNewTab extends OrcaRuntim commit: (move: TerminalLeafMoveRequest) => Promise, placement?: MovedPanePlacement ): Promise { + this.clientSessionTabSelections.noteHostNavigation() const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id diff --git a/src/main/runtime/session-tabs/headless-tab-properties.ts b/src/main/runtime/session-tabs/headless-tab-properties.ts index e3e58dfccbda..676591fbb3f4 100644 --- a/src/main/runtime/session-tabs/headless-tab-properties.ts +++ b/src/main/runtime/session-tabs/headless-tab-properties.ts @@ -38,6 +38,9 @@ export abstract class RuntimeHeadlessTabProperties extends OrcaRuntimeWithHeadle worktreeSelector: string, args: SessionTabProps ): Promise<{ updated: true }> { + if (args.viewMode !== undefined) { + this.clientSessionTabSelections.noteHostNavigation() + } const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id diff --git a/src/main/runtime/session-tabs/headless-terminal-tab-close.ts b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts index 8446eff1c669..fb16b69f2a09 100644 --- a/src/main/runtime/session-tabs/headless-terminal-tab-close.ts +++ b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts @@ -150,6 +150,9 @@ export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends Orca worktreeSelector: string, move: RuntimeMobileSessionTabMove ): Promise { + if (move.kind !== 'reorder') { + this.clientSessionTabSelections.noteHostNavigation() + } const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id diff --git a/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.test.ts b/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.test.ts index f66772ba4a83..f6e83e9ff206 100644 --- a/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.test.ts +++ b/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.test.ts @@ -1,9 +1,10 @@ -// Layout-owner build, no window: an agent launch's early tab in a workspace main owns is written by -// main, a retry finds it, and a withdrawn launch pane is taken back by main. +import { resetAgentLaunchPanesForTests } from '../../agent-launch/agent-launch-pane-attachment' import { afterEach, describe, expect, it, vi } from 'vitest' import { WORKTREE_ID, setupMainOwnedWorkspace } from '../main-owned-workspace-runtime-fixture' import { publishAgentLaunchTabEarly } from '../rpc/methods/agent-launch-tab-publication' -import { resetAgentLaunchPanesForTests } from '../../agent-launch/agent-launch-pane-attachment' + +// Layout-owner build, no window: an agent launch's early tab in a workspace main owns is written by +// main, a retry finds it, and a withdrawn launch pane is taken back by main. vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) diff --git a/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.ts b/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.ts index 07b4beb5a22c..578cb9236723 100644 --- a/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.ts +++ b/src/main/runtime/session-tabs/main-layout-agent-launch-tabs.ts @@ -1,4 +1,5 @@ -// Main-owned early launch tabs are written before launch and withdrawn through main close; paired-runtime workspaces retain window relay. +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { launchTabWithdrawalIdentity } from '../../../shared/agent-launch-tab-publication' import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './structured-session-tab-close' import type { AgentLaunchTabPublished, @@ -10,6 +11,8 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' +// Main-owned early launch tabs are written before launch and withdrawn through main close; paired-runtime workspaces retain window relay. + /** Where the tab landed against where the launch asked for it, as the window reports it. */ function placementFallback( asked: AgentLaunchTabPublishRequest['placement'], @@ -48,25 +51,82 @@ export abstract class OrcaRuntimeWithMainLayoutAgentLaunchTabs extends OrcaRunti )?.groupId } + private launchWithdrawalIdentity(worktreeId: string, tabId: string): string { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + return launchTabWithdrawalIdentity( + session?.terminalLayoutsByTabId?.[tabId]?.root, + session?.unifiedTabs?.[worktreeId] ?? [], + session?.tabGroups?.[worktreeId] ?? [], + tabId + ) + } + /** The launch's tab in main's layout; null for a workspace main does not own (today's path). */ publishAgentLaunchTabInMainLayout( - request: Omit + request: Omit, + check?: () => void, + selectionCurrent?: (consume?: boolean) => boolean ): Promise | null { const { worktreeId, tabId, leafId, placement } = request if (!this.ownsLaunchTab(worktreeId, tabId)) { return null } const existed = this.groupOfTab(worktreeId, tabId) !== undefined - return this.applyLayoutCommand({ - type: 'createTerminalTab', - workspace: worktreeId, - tabId, - leafId, - viewMode: request.viewMode, - ...(placement?.groupId ? { groupId: placement.groupId } : {}), - ...(placement?.afterTabId ? { afterTabId: placement.afterTabId } : {}), - creation: { launchAgent: request.launchAgent } - }).then((reply) => { + let withdrawalRoot: string | undefined + return this.applyLayoutCommand( + { + type: 'createTerminalTab', + workspace: worktreeId, + tabId, + leafId, + viewMode: request.viewMode, + ...(placement?.groupId ? { groupId: placement.groupId } : {}), + ...(placement?.afterTabId ? { afterTabId: placement.afterTabId } : {}), + ...(placement?.index !== undefined ? { index: placement.index } : {}), + creation: { launchAgent: request.launchAgent } + }, + { + ...(check + ? { + admit: () => { + check() + return true + } + } + : {}), + onApplied: () => { + withdrawalRoot = this.launchWithdrawalIdentity(worktreeId, tabId) + const openPlacement = request.openPlacement + if (openPlacement) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + force: true + }) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (snapshot) { + this.storeMobileSessionSnapshot(worktreeId, { + ...snapshot, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.parentTabId === tabId && tab.leafId === leafId + ? { + ...tab, + openPlacement: { + ...openPlacement, + activate: + openPlacement.activate && + (isSessionTabSelectionRequest(openPlacement.viewerSelection) || + (selectionCurrent?.() ?? true)) + } + } + : tab + ) + }) + this.markPaneLiveTitlesDirty() + } + } + } + } + ).then((reply) => { if (!reply.ok) { throw new Error(reply.code) } @@ -74,7 +134,16 @@ export abstract class OrcaRuntimeWithMainLayoutAgentLaunchTabs extends OrcaRunti if (!landed) { throw new Error('agent_launch_tab_unplaced') } - this.showLaunchTabInWindow(request, { frameSeq: reply.frameSeq, created: !existed }) + this.showLaunchTabInWindow( + { + ...request, + viewer: + isSessionTabSelectionRequest(request.viewerSelection) || (selectionCurrent?.() ?? true) + ? request.viewer + : 'none' + }, + { frameSeq: reply.frameSeq, created: !existed } + ) // A retry naming the same tab finds it: the launch made no tab, so it takes none back. if (existed) { return { tabId, created: false, placement: { groupId: landed } } @@ -86,6 +155,7 @@ export abstract class OrcaRuntimeWithMainLayoutAgentLaunchTabs extends OrcaRunti return { tabId, created: true, + withdrawalRoot, placement: { groupId: landed, ...(fallback ? { fallback } : {}) } } }) @@ -108,28 +178,70 @@ export abstract class OrcaRuntimeWithMainLayoutAgentLaunchTabs extends OrcaRunti } /** Withdraw main-owned launch panes, closing the tab for its last pane; false leaves unowned workspaces to window cleanup. */ - withdrawAgentLaunchPaneInMainLayout(pane: AgentLaunchPaneAddress): boolean { - if (!this.ownsLaunchTab(pane.worktreeId, pane.tabId)) { + async withdrawAgentLaunchPaneInMainLayout( + pane: AgentLaunchPaneAddress, + current: () => boolean = () => true, + withdrawalRoot?: string, + publicationOwner?: 'main' | 'window' + ): Promise { + if (!withdrawalRoot || !current()) { return false } - // The verdict is a report: a failed take-back leaves an idle pane, never a failed launch. - void this.takeBackLaunchPane(pane).catch((error: unknown) => { - console.warn('[agent-launch] could not take back a withdrawn launch pane', error) - }) - return true + if ( + publicationOwner === 'window' || + (publicationOwner === undefined && !this.ownsLaunchTab(pane.worktreeId, pane.tabId)) + ) { + if (!this.supportsConditionalSwitchProjection()) { + return false + } + const published = this.notifier?.publishAgentLaunchTab?.({ + ...pane, + launchAgent: 'codex', + viewMode: 'terminal', + viewer: 'none', + withdraw: { root: withdrawalRoot } + }) + return (await published)?.withdrawn === true + } + return this.takeBackLaunchPane(pane, current, withdrawalRoot) } - private async takeBackLaunchPane(pane: AgentLaunchPaneAddress): Promise { + private async takeBackLaunchPane( + pane: AgentLaunchPaneAddress, + current: () => boolean, + captured: string + ): Promise { const { worktreeId: workspace, tabId, leafId } = pane - const reply = await this.applyLayoutCommand({ type: 'closePane', workspace, tabId, leafId }) + const read = () => this.getWorkspaceSessionForWorktree(workspace) + const admit = () => { + const session = read() + const root = session?.terminalLayoutsByTabId?.[tabId]?.root + return ( + current() && + root?.type === 'leaf' && + root.leafId === leafId && + this.launchWithdrawalIdentity(workspace, tabId) === captured && + !this.hasLiveTerminalForPaneKey(`${tabId}:${leafId}`) && + !session?.terminalPtyIncarnationsByPaneKey?.[`${tabId}:${leafId}`] + ) + } + const reply = await this.applyLayoutCommand( + { type: 'closePane', workspace, tabId, leafId }, + { admit } + ) // A pane close keeps a tab's last pane; that pane goes with its tab. if (!reply.ok && reply.code === 'last_pane') { - await this.applyLayoutCommand({ - type: 'closeTabs', - workspace, - tabIds: [tabId], - recordClose: 'cleanup' - }) + const closed = await this.applyLayoutCommand( + { + type: 'closeTabs', + workspace, + tabIds: [tabId], + recordClose: 'cleanup' + }, + { admit } + ) + return closed.ok } + return reply.ok } } diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index 0fd652a42fe6..fcfeab4588bc 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -1,5 +1,6 @@ // Use main's rows/order/groups, augmented by window-only rows and live view facts (selection, dirty state, titles, theme/status and editor drafts). +import { preserveSessionTabOpenPlacement } from './session-tab-open-placement' import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement' import type { @@ -127,7 +128,10 @@ export function withMainSessionTabRows( // A main-held row is main's; a browser or chat row stays the window's while both list it. const rows = projected.tabs.map((row) => isMainRow(row) - ? withWindowFacts(row, windowRows.get(row.id), rules) + ? preserveSessionTabOpenPlacement( + withWindowFacts(row, windowRows.get(row.id), rules), + windowRows.get(row.id) + ) : (windowRows.get(row.id) ?? row) ) const windowOnlyIds = new Set(windowOnly.map((row) => row.id)) diff --git a/src/main/runtime/session-tabs/paired-host-terminal-close.ts b/src/main/runtime/session-tabs/paired-host-terminal-close.ts index 6300836648f0..485f87b77c4c 100644 --- a/src/main/runtime/session-tabs/paired-host-terminal-close.ts +++ b/src/main/runtime/session-tabs/paired-host-terminal-close.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime/remote-runtime-pty-id' import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -10,7 +11,11 @@ export type PairedHostTerminal = { environmentId: string; handle: string } export type PairedHostTerminalCall = ( environmentId: string, method: 'terminal.close' | 'terminal.closeTab', - params: { terminal: string }, + params: { + terminal: string + expectedIncarnationId?: string + originalPresentation?: TerminalSurfaceCloseCapture + }, timeoutMs: number ) => Promise @@ -54,18 +59,22 @@ function errorCode(error: unknown): string | null { export async function closeTerminalOnPairedHost( call: PairedHostTerminalCall, terminal: PairedHostTerminal, - method: 'terminal.close' | 'terminal.closeTab' + method: 'terminal.close' | 'terminal.closeTab', + original?: { expectedIncarnationId?: string; originalPresentation?: TerminalSurfaceCloseCapture } ): Promise> { let result: unknown try { result = await call( terminal.environmentId, method, - { terminal: terminal.handle }, + { terminal: terminal.handle, ...original }, PAIRED_HOST_TERMINAL_CLOSE_TIMEOUT_MS ) } catch (error) { const code = errorCode(error) + if (original?.originalPresentation) { + throw error + } if (code === 'terminal_not_found') { // The owner has no such terminal, so nothing runs there to stop. return { ptyKilled: false } @@ -82,6 +91,9 @@ export async function closeTerminalOnPairedHost( } const close = readHostClose(result) if (!close) { + if (original?.originalPresentation) { + throw new Error('terminal_close_unverifiable') + } // Why unverifiable: the owner answered, but not with a receipt this build can read. return { ptyKilled: false, @@ -89,6 +101,9 @@ export async function closeTerminalOnPairedHost( ptyStopReason: 'its owning host returned no close receipt' } } + if (original?.originalPresentation && !close.ptyKilled) { + throw new Error('terminal_close_unverifiable') + } return close } diff --git a/src/main/runtime/session-tabs/session-tab-open-placement.ts b/src/main/runtime/session-tabs/session-tab-open-placement.ts new file mode 100644 index 000000000000..e451bd608c48 --- /dev/null +++ b/src/main/runtime/session-tabs/session-tab-open-placement.ts @@ -0,0 +1,81 @@ +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import type { RuntimeMobileSessionSnapshotTab as Row } from '../../../shared/runtime-mobile-session-tab-contracts' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' + +/** Rebuilds retain the open intent only for the same ordinary destination resource. */ +export function preserveSessionTabOpenPlacement(row: Row, prior: Row | undefined): Row { + if (row.type === 'agent-session' && prior?.type === 'agent-session') { + return row.openPlacement || row.id !== prior.id || row.sessionId !== prior.sessionId + ? row + : { ...row, ...(prior.openPlacement ? { openPlacement: prior.openPlacement } : {}) } + } + if ( + row.type === 'terminal' && + prior?.type === 'terminal' && + !row.openPlacement && + prior.openPlacement && + row.id === prior.id && + row.parentTabId === prior.parentTabId && + row.leafId === prior.leafId && + (!prior.ptyId || row.ptyId === prior.ptyId) && + (!prior.incarnationId || row.incarnationId === prior.incarnationId) + ) { + return { ...row, openPlacement: prior.openPlacement } + } + return row +} + +export function preserveSessionTabOpenPlacements( + snapshot: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined +): RuntimeMobileSessionTabsSnapshot { + if ( + !existing || + (snapshot.worktreeInstanceId && + existing.worktreeInstanceId && + snapshot.worktreeInstanceId !== existing.worktreeInstanceId) + ) { + return snapshot + } + const prior = new Map(existing.tabs.map((row) => [row.id, row])) + return { + ...snapshot, + tabs: snapshot.tabs.map((row) => preserveSessionTabOpenPlacement(row, prior.get(row.id))) + } +} + +/** Frames and snapshots carry the same unsaved intent from the ordinary row owner. */ +export function withPublishedSessionTabOpenPlacements( + layout: PublishedWorkspaceLayout, + snapshot: RuntimeMobileSessionTabsSnapshot | undefined +): PublishedWorkspaceLayout { + const chats = new Map>() + const terminals = new Map[]>() + for (const row of snapshot?.tabs ?? []) { + if (row.type === 'agent-session' && row.openPlacement) { + chats.set(row.sessionId, row) + } else if (row.type === 'terminal' && row.openPlacement) { + const siblings = terminals.get(row.parentTabId) ?? [] + siblings.push(row) + terminals.set(row.parentTabId, siblings) + } + } + if (chats.size === 0 && terminals.size === 0) { + return layout + } + return { + ...layout, + tabs: layout.tabs.map((tab) => { + let openPlacement = + tab.kind === 'agent-session' ? chats.get(tab.entityId)?.openPlacement : undefined + if (tab.kind === 'terminal') { + const panes = new Map(tab.terminal?.panes.map((pane) => [pane.leafId, pane])) + openPlacement = terminals.get(tab.id)?.find((row) => { + const pane = panes.get(row.leafId) + return pane && (!row.ptyId || pane.ptyId === row.ptyId) + })?.openPlacement + } + return openPlacement ? { ...tab, openPlacement } : tab + }) + } +} diff --git a/src/main/runtime/session-tabs/session-tabs-graph-changes.ts b/src/main/runtime/session-tabs/session-tabs-graph-changes.ts new file mode 100644 index 000000000000..6a66f81b9de5 --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-graph-changes.ts @@ -0,0 +1,41 @@ +import type { RuntimeSyncedTab } from '../../../shared/runtime-types' +import type { RuntimeLeafRecord } from '../runtime-terminal-state-records' +import { OrcaRuntimeWithSyncWindowGraph } from '../session-graph/session-window-graph' + +export abstract class RuntimeSessionTabsGraphChanges extends OrcaRuntimeWithSyncWindowGraph { + // Graph changes can alter projected handles/titles without changing snapshot bytes; compare projection fields and fan out affected workspaces. + protected collectMobileVisibleGraphChangedWorktrees( + previousTabs: Map, + previousLeaves: Map + ): Set { + const changed = new Set() + for (const [tabId, tab] of this.tabs) { + const prev = previousTabs.get(tabId) + if (!prev || prev.title !== tab.title) { + changed.add(tab.worktreeId) + } + } + for (const [tabId, tab] of previousTabs) { + if (!this.tabs.has(tabId)) { + changed.add(tab.worktreeId) + } + } + for (const [leafKey, leaf] of this.leaves) { + const prev = previousLeaves.get(leafKey) + if ( + !prev || + prev.ptyId !== leaf.ptyId || + prev.connected !== leaf.connected || + prev.paneTitle !== leaf.paneTitle + ) { + changed.add(leaf.worktreeId) + } + } + for (const [leafKey, leaf] of previousLeaves) { + if (!this.leaves.has(leafKey)) { + changed.add(leaf.worktreeId) + } + } + return changed + } +} diff --git a/src/main/runtime/session-tabs/session-tabs-inventory.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts index 8d242e7c0de4..691b48050659 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -6,7 +6,7 @@ import type { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs as BuildHeadl import type { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals as RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods } from './renderer-session-terminal-rescue' import type { OrcaRuntimeWithTouchMobileSessionTabsForWorktree as TouchMobileSessionTabsForWorktreeMethods } from './mobile-session-membership' import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../session-lifecycle/projection/prune-mobile-session-tab-group-layout' -import { OrcaRuntimeWithSyncWindowGraph } from '../session-graph/session-window-graph' +import { RuntimeSessionTabsGraphChanges } from './session-tabs-graph-changes' import { mainTerminalSurfaces, type MainTerminalSurfacesHost @@ -14,16 +14,15 @@ import { import type { WorkspaceLayoutReads } from '../workspace-layout-stream' import type { RuntimeMobileSessionTabsResult, - RuntimeSyncedTab, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' -import type { RuntimeLeafRecord } from '../runtime-terminal-state-records' import type { PtyControllerInventory } from '../runtime-pty-controller-contract' import { parseExecutionHostId } from '../../../shared/execution-host' import type { PaneStartStatuses } from '../pane-start-statuses' +import type { AgentSessionSwitchResult } from '../../../shared/agent-session-switch-execution' import { paneOfPty, type PtyPane, type PtyPaneSource } from '../terminal/surface/pty-pane-query' -export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph { +export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends RuntimeSessionTabsGraphChanges { abstract get workspaceLayoutReads(): WorkspaceLayoutReads declare protected readonly paneStartStatuses: PaneStartStatuses | null @@ -140,42 +139,6 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e > ): ReturnType - // Graph changes can alter projected handles/titles without changing snapshot bytes; compare projection fields and fan out affected workspaces. - protected collectMobileVisibleGraphChangedWorktrees( - previousTabs: Map, - previousLeaves: Map - ): Set { - const changed = new Set() - for (const [tabId, tab] of this.tabs) { - const prev = previousTabs.get(tabId) - if (!prev || prev.title !== tab.title) { - changed.add(tab.worktreeId) - } - } - for (const [tabId, tab] of previousTabs) { - if (!this.tabs.has(tabId)) { - changed.add(tab.worktreeId) - } - } - for (const [leafKey, leaf] of this.leaves) { - const prev = previousLeaves.get(leafKey) - if ( - !prev || - prev.ptyId !== leaf.ptyId || - prev.connected !== leaf.connected || - prev.paneTitle !== leaf.paneTitle - ) { - changed.add(leaf.worktreeId) - } - } - for (const [leafKey, leaf] of previousLeaves) { - if (!this.leaves.has(leafKey)) { - changed.add(leaf.worktreeId) - } - } - return changed - } - async listMobileSessionTabs( worktreeSelector: string, clientNavigationId?: string, @@ -206,6 +169,29 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId, options) } + currentSessionSwitchDestination( + worktreeId: string, + result: AgentSessionSwitchResult + ): boolean | undefined { + if (this.getAuthoritativeSessionTabsInventoryEpoch() === null) { + return undefined + } + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if ( + !('terminal' in result && result.terminal) && + this.structuredAgentSessionInventoryUnverifiable + ) { + return undefined + } + return ( + snapshot?.tabs.some((tab) => + 'terminal' in result && result.terminal + ? tab.type === 'terminal' && tab.id === result.terminal + : tab.type === 'agent-session' && tab.sessionId === result.sessionId + ) ?? false + ) + } + async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { diff --git a/src/main/runtime/session-tabs/structured-session-account-home.ts b/src/main/runtime/session-tabs/structured-session-account-home.ts index 99484b2a3dca..68360f4bf41e 100644 --- a/src/main/runtime/session-tabs/structured-session-account-home.ts +++ b/src/main/runtime/session-tabs/structured-session-account-home.ts @@ -1,8 +1,9 @@ +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { structuredLaunchSeedOptions } from '../../../shared/native-chat/input/native-chat-session-option-defaults' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import { structuredAgentRuntimeMetadata } from '../structured-agent-runtime-metadata' import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' import { OrcaRuntimeWithStructuredSessionCliWriterHost } from './structured-session-cli-writer-host' - export abstract class OrcaRuntimeWithStructuredSessionAccountHome extends OrcaRuntimeWithStructuredSessionCliWriterHost { /** Resolve account home through this host's agent registration; null means unregistered and create refuses. */ protected structuredAgentAccountHomeResolver( @@ -38,4 +39,20 @@ export abstract class OrcaRuntimeWithStructuredSessionAccountHome extends OrcaRu } ) } + + protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { + const definition = structuredAgentRuntimeMetadata(agent)?.definition + if (!definition) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return definition + } + + structuredAgentSessionLaunchSeedOptions( + agent: StructuredAgentId + ): Record | undefined { + return structuredLaunchSeedOptions(this.requireStore().getSettings(), agent) + } } diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index d7ebdc6a274c..9082bc3569a4 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -20,14 +20,12 @@ import { } from '../structured-agent-launch-support' import { structuredAgentRuntimeMetadata } from '../structured-agent-runtime-metadata' import { structuredAgentChatProgramNeedsInstall } from '../structured-agent-availability' -import { structuredLaunchSeedOptions } from '../../../shared/native-chat/input/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from '../structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from '../structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' import { isFloatingWorkspaceSelector } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' - export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends StructuredSessionWorkspaceScope { protected abstract restoreStructuredAgentSessionTabsOnce( ...args: Parameters< @@ -59,24 +57,6 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return support } - /** Use this runtime's registered definition to pin account home; reads/create must not install the host just to inspect it. */ - protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { - const definition = structuredAgentRuntimeMetadata(agent)?.definition - if (!definition) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } - return definition - } - - /** Share new-chat selection between create and createSupport so client pickers show what create will run. */ - structuredAgentSessionLaunchSeedOptions( - agent: StructuredAgentId - ): Record | undefined { - return structuredLaunchSeedOptions(this.requireStore().getSettings(), agent) - } - async resolveStructuredAgentSessionCreateIntent( input: StructuredAgentSessionCreateIntentInput ): Promise { @@ -99,6 +79,14 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte throw ownerConflict } } + return this.resolveStructuredAgentSessionCreateIdentity(input, 'launch') + } + + /** Resolve registered account/transcript identity without acquiring or admitting a writer. */ + async resolveStructuredAgentSessionCreateIdentity( + input: StructuredAgentSessionCreateIntentInput, + purpose: 'read' | 'launch' = 'read' + ): Promise { const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? (input.resumeFrom?.cliSource @@ -114,7 +102,7 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte const resolveAccountHome = this.structuredAgentAccountHomeResolver( input.agent, input.worktree, - 'launch', + purpose, hostLaunchDirectory ) if (!resolveAccountHome) { @@ -124,7 +112,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte } const resolved = await this.resolveStructuredAgentSessionIntent( { ...input, structuredAgentCliSourceEvidence }, - resolveAccountHome + resolveAccountHome, + purpose ) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -155,7 +144,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte workspaceId: string workspaceKind: 'folder' | 'git-worktree' } - }) => AgentSessionAccountHome | Promise + }) => AgentSessionAccountHome | Promise, + purpose: 'read' | 'launch' = 'launch' ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) // Adopting a conversation reads the agent's own transcript, so only an agent registered with an @@ -183,7 +173,7 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return committedReplay } const resumeFrom = input.resumeFrom - if (resumeFrom) { + if (purpose === 'launch' && resumeFrom) { await this.assertStructuredAdoptionTerminalWritersExited( input.agent, resumeFrom.providerSessionId, @@ -209,7 +199,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte workspaceId: location.workspaceId, evidence: input.structuredAgentCliSourceEvidence }), - prepareCodexResume: this.prepareAiVaultSessionResumeFn ?? undefined, + prepareCodexResume: + purpose === 'launch' ? (this.prepareAiVaultSessionResumeFn ?? undefined) : undefined, selectedAccountHome, launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), workspaceCwd: workspaceCwd diff --git a/src/main/runtime/session-tabs/structured-session-launch-runtime.ts b/src/main/runtime/session-tabs/structured-session-launch-runtime.ts new file mode 100644 index 000000000000..2ba5e2ecbedd --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-launch-runtime.ts @@ -0,0 +1,133 @@ +import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './structured-session-create-support' +import type { RuntimeRepoSearchRefs } from '../../../shared/runtime-types' +import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from '../orca-runtime-postlude' +import type { Repo } from '../../../shared/repo-types' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { + getLocalProjectGhExecOptions, + type LocalProjectGhExecOptions +} from '../../project-runtime-git-options' +import { + agentLaunchPlatformForScope, + newWorktreeLaunchScope, + resolveAgentLaunchRuntime, + resolveStartupAgentLaunchRuntime +} from '../agent-launch-scope-runtime' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentLaunchRuntime } from '../../../shared/agent-launch-runtime' +import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { + PtyProcessInspection, + PtyProcessInspectionOptions +} from '../../providers/pty-process-inspection' + +export abstract class OrcaRuntimeWithStructuredSessionLaunchRuntime extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { + async inspectTerminalProcess( + terminalSelector: string, + options?: PtyProcessInspectionOptions + ): Promise { + const leaf = this.resolveLiveLeafForHandle(terminalSelector) + if (!leaf?.ptyId || !this.ptyController) { + throw new Error('terminal_gone') + } + if (this.ptyController.inspectProcess) { + // Preserve one-argument unfenced calls; providers may use arity to select legacy versus fenced inspection. + const inspection = + options === undefined + ? await this.ptyController.inspectProcess(leaf.ptyId) + : await this.ptyController.inspectProcess(leaf.ptyId, options) + const evidence = inspection.foregroundProcessEvidence + // The runtime handle is the request identity on this wire; keep the + // host-owned leaf PTY id out of the client-facing comparison. + const relayPtyId = parseAppSshPtyId(leaf.ptyId)?.relayPtyId + const evidenceBelongsToLeaf = + evidence !== undefined && (evidence.ptyId === leaf.ptyId || evidence.ptyId === relayPtyId) + return evidenceBelongsToLeaf + ? { ...inspection, foregroundProcessEvidence: { ...evidence, ptyId: terminalSelector } } + : inspection + } + const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) + const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false + return { foregroundProcess, hasChildProcesses } + } + + async searchRepoRefs( + repoSelector: string, + query: string, + limit = DEFAULT_REPO_SEARCH_REFS_LIMIT, + includeQualifiedRefs = true + ): Promise { + return this.repositoryRefQueries.search(repoSelector, query, limit, includeQualifiedRefs) + } + + protected async resolveHostedReviewTarget(args: { + repoSelector: string + worktreeSelector?: string + }): Promise<{ repo: Repo; repoPath: string }> { + const repo = await this.resolveRepoSelector(args.repoSelector) + if (!args.worktreeSelector) { + return { repo, repoPath: repo.path } + } + + const worktree = await this.resolveGitWorktreeSelector(args.worktreeSelector) + if (worktree.repoId !== repo.id) { + throw new Error('Access denied: worktree does not belong to repository') + } + return { repo, repoPath: worktree.path } + } + + protected getHostedReviewExecutionOptions( + repo: Repo, + admissionTier?: GitAdmissionTier + ): + | { localGitExecOptions: LocalProjectGhExecOptions & { admissionTier?: GitAdmissionTier } } + | undefined { + const localGitOptions = { + ...this.getLocalGitExecutionOptionArgs(repo)[0], + ...(admissionTier && { admissionTier }) + } + return Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : undefined + } + + protected getLocalGitExecutionOptionArgs(repo: Repo): [] | [LocalProjectGhExecOptions] { + const localGitOptions = getLocalProjectGhExecOptions(this.requireStore(), repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] + } + + protected getAgentLaunchPlatformForWorkspace( + scope: Pick & { + connectionId?: string | null + } + ): NodeJS.Platform { + // Orchestration passes a repo-only scope; the platform never reads its id or folder. + return agentLaunchPlatformForScope(this.requireStore(), { + id: '', + folderWorkspace: null, + ...scope, + connectionId: scope.connectionId ?? null + }) + } + + protected resolveAgentLaunchRuntime( + scope: TerminalWorkspaceLaunchScope, + agent: TuiAgent | undefined + ): AgentLaunchRuntime { + return resolveAgentLaunchRuntime(this.requireStore(), scope, undefined, agent) + } + + protected resolveStartupAgentLaunchRuntime( + repo: Repo, + agent: TuiAgent, + scope?: TerminalWorkspaceLaunchScope + ): ReturnType { + const store = this.requireStore() + return resolveStartupAgentLaunchRuntime( + store, + scope ?? newWorktreeLaunchScope(repo, store.getSettings()), + agent + ) + } +} diff --git a/src/main/runtime/session-tabs/structured-session-switch-projection.ts b/src/main/runtime/session-tabs/structured-session-switch-projection.ts new file mode 100644 index 000000000000..eefc366d8e46 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-projection.ts @@ -0,0 +1,192 @@ +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' + +import { retireStructuredAgentSessionTabFrom } from '../agent-session/structured-agent-session-tab-retirement' +import type { ChatTabChange } from '../agent-session/agent-session-tab-layout' +import { createChatTabLayoutQueue } from '../agent-session-tab-layout-queue' +import type { LayoutCommandRunOptions, WindowLayoutCommand } from '../runtime-layout-apply' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' + +import type { SessionSwitchProjection } from '../../../shared/agent-session-switch-execution' +import { OrcaRuntimeWithStructuredSessionTabProjection } from './restored-chat-tab-changes' + +import { loadChatTabLayout } from '../agent-session/agent-session-tab-layout' + +export abstract class OrcaRuntimeWithStructuredSessionSwitchProjection extends OrcaRuntimeWithStructuredSessionTabProjection { + protected abstract changeWindowlessChatTabs( + worktreeId: string, + changes: readonly ChatTabChange[] + ): void + retainSessionSwitchProjection( + worktreeId: string, + sessionId: string, + invocationId: string, + generation: string + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + const next = this.storeMobileSessionSnapshot(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + switchProjections: [ + ...(snapshot.switchProjections ?? []).filter((item) => item.sessionId !== sessionId), + { + sessionId, + invocationId, + generation, + tabId: `agent-session:${sessionId}`, + phase: 'retain' + } + ] + }) + this.emitMobileSessionTabsSnapshot(next) + } + + convergeSessionSwitchProjection(worktreeId: string, generation: string): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot?.switchProjections?.some((item) => item.generation === generation)) { + return + } + const next = this.storeMobileSessionSnapshot(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + switchProjections: snapshot.switchProjections.filter((item) => item.generation !== generation) + }) + this.emitMobileSessionTabsSnapshot(next) + } + + captureSessionSwitchRetirement( + worktreeId: string, + sessionId: string, + tabId: string | undefined, + workCurrent: () => boolean, + restore: () => Promise, + projection?: SessionSwitchProjection + ) { + const read = () => { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const workspace = + session && + loadChatTabLayout( + this.getWorkspaceSessionHostIdForWorktree(worktreeId), + session, + worktreeId + ).loaded.layout.workspaces[worktreeId] + const tab = workspace?.tabs.find( + (row) => row.id === tabId && row.kind === 'agent-session' && row.entityId === sessionId + ) + const group = workspace?.groups.find((row) => row.tabOrder.includes(tabId ?? '')) + return tab && group + ? { tab, group: group.id, before: group.tabOrder.slice(0, group.tabOrder.indexOf(tab.id)) } + : null + } + const captured = JSON.stringify(read()) + const presentationCurrent = () => + captured === 'null' ? windowAvailable && viewerSupported : JSON.stringify(read()) === captured + const announce = (phase: 'prepare' | 'retire' | 'retain') => { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot || !projection) { + return + } + const next = this.storeMobileSessionSnapshot(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + switchProjections: [ + ...(snapshot.switchProjections ?? []).filter((item) => item.sessionId !== sessionId), + { ...projection, phase } + ] + }) + this.emitMobileSessionTabsSnapshot(next) + } + announce('prepare') + const windowAvailable = Boolean(this.getAvailableAuthoritativeWindow()) + const viewerSupported = !windowAvailable || this.supportsConditionalSwitchProjection() + const capturedWindow = + viewerSupported && projection && tabId && windowAvailable + ? Promise.resolve( + this.notifier?.closeSessionTab?.(tabId, worktreeId, { ...projection, phase: 'prepare' }) + ).catch(() => false) + : undefined + const retire = async () => { + const host = getStructuredAgentSessionHost() + if (!host || !tabId) { + return false + } + if (!viewerSupported) { + await restore() + return false + } + const admit = () => workCurrent() && presentationCurrent() + if (capturedWindow && (await capturedWindow) !== true) { + await restore() + return false + } + if (!admit()) { + await restore() + return false + } + if ( + !(await host.deps.store.setSwitchedSessionTabVisibility(sessionId, tabId, false, admit)) + ) { + await restore() + return false + } + if ( + windowAvailable && + projection && + (await this.notifier?.closeSessionTab?.(tabId, worktreeId, { + ...projection, + phase: 'retire' + })) !== true + ) { + await restore() + return false + } + const closed = ORCA_LAYOUT_OWNER_IS_MAIN + ? await this.queueChatTabChanges( + () => [{ worktreeId, change: { close: sessionId } }], + admit + ) + : windowAvailable + ? admit() + : admit() && (this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]), true) + if (!closed || !workCurrent()) { + await restore() + return false + } + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!workCurrent()) { + await restore() + return false + } + const next = snapshot && retireStructuredAgentSessionTabFrom(snapshot, sessionId) + if (next) { + this.storeMobileSessionSnapshot(worktreeId, next) + this.emitMobileSessionTabsSnapshot(next) + } + announce('retire') + host.notifySessionTabHidden(sessionId) + return true + } + return Object.assign(retire, { + placement: () => { + const current = read() + return current + ? { groupId: current.group, afterTabId: current.tab.id, index: current.before.length } + : undefined + } + }) + } + + abstract applyLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise + protected readonly queueChatTabChanges = createChatTabLayoutQueue({ + sessionOf: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), + hostIdOf: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId), + apply: (command, options) => this.applyLayoutCommand(command, options) + }) +} diff --git a/src/main/runtime/session-tabs/structured-session-tab-close.ts b/src/main/runtime/session-tabs/structured-session-tab-close.ts index 267155a0dc44..6a40a8a6ad31 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-close.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-close.ts @@ -1,8 +1,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as BrowserOwnership } from './mobile-session-publication-preservation' import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../session-lifecycle/projection/prune-mobile-session-tab-group-layout' import type { RuntimeHeadlessTabGroups as HeadlessTabGroupsMethods } from './headless-tab-group-moves' -import { OrcaRuntimeWithStructuredSessionTabProjection } from './restored-chat-tab-changes' import type { BrowserTabInfo, RuntimeMobileSessionAgentTab, @@ -23,15 +23,13 @@ import { type ChatTabChange, type ChatTabPlacement } from '../agent-session/agent-session-tab-layout' -import { createChatTabLayoutQueue } from '../agent-session-tab-layout-queue' -import type { WindowLayoutCommand } from '../runtime-layout-apply' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' import { savedSessionId } from '../saved-structured-agent-session-restoration' import { structuredSessionTabRetired } from '../structured-worker-custody' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { OrcaRuntimeWithStructuredSessionSwitchProjection } from './structured-session-switch-projection' -export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithStructuredSessionTabProjection { +export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithStructuredSessionSwitchProjection { protected abstract isHeadlessOwnedMobileBrowserTab( ...args: Parameters ): ReturnType @@ -49,8 +47,6 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca ...args: Parameters ): ReturnType - abstract applyLayoutCommand(command: WindowLayoutCommand): Promise - protected abstract persistHeadlessTabGroups( ...args: Parameters ): ReturnType @@ -79,6 +75,13 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca } } + abstract publishStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: StructuredAgentId + activate: boolean + }): Promise + // Refused echo closes need a newer snapshot so clients restore the still-live mirror they already pruned. protected republishMobileSessionTabsSnapshot(worktreeId: string): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) @@ -301,25 +304,26 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca ) return } - const session = this.getAvailableAuthoritativeWindow() - ? null - : this.getWorkspaceSessionForWorktree(worktreeId) - const next = - session && + this.commitWindowlessChatSession(worktreeId, (session) => withChatTabChanges( this.getWorkspaceSessionHostIdForWorktree(worktreeId), session, worktreeId, eligible ? changes.filter(eligible) : changes ) + ) + } + + protected commitWindowlessChatSession( + worktreeId: string, + update: (session: WorkspaceSessionState) => WorkspaceSessionState | null + ): void { + const session = this.getAvailableAuthoritativeWindow() + ? null + : this.getWorkspaceSessionForWorktree(worktreeId) + const next = session && update(session) if (next) { this.setWorkspaceSessionForWorktree(worktreeId, next) } } - - private readonly queueChatTabChanges = createChatTabLayoutQueue({ - sessionOf: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), - hostIdOf: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId), - apply: (command) => this.applyLayoutCommand(command) - }) } diff --git a/src/main/runtime/session-tabs/structured-session-tab-projection.ts b/src/main/runtime/session-tabs/structured-session-tab-projection.ts new file mode 100644 index 000000000000..c8855c833b48 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-tab-projection.ts @@ -0,0 +1,96 @@ +import { defaultAgentChatLabel } from '../../../shared/agent-session/agent-session-chat-label' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import type { SessionTabOpenPlacement } from '../../../shared/session-tab-placement' +import { + insertTabIdIntoOrder, + partitionPinnedTabOrder +} from '../../../shared/workspace-layout/tab-order' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' +import { getHeadlessMobileSessionGroupId } from '../mobile-session-layout-projection' + +export type StructuredSessionTabProjection = { + workspaceId: string + sessionId: string + agent: StructuredAgentId + activate: boolean + openPlacement?: SessionTabOpenPlacement +} + +/** Publish membership and its addressed open together; existing destinations keep their current place. */ +export function projectStructuredSessionTab( + existing: RuntimeMobileSessionTabsSnapshot | undefined, + input: StructuredSessionTabProjection +): RuntimeMobileSessionTabsSnapshot | null { + const id = `agent-session:${input.sessionId}` + const listed = existing?.tabs.some((tab) => tab.id === id) + if (listed && !input.activate && !input.openPlacement) { + return null + } + const tabs = [...(existing?.tabs ?? [])].map((tab) => ({ + ...tab, + isActive: input.activate ? tab.id === id : tab.isActive, + ...(tab.id === id && input.openPlacement ? { openPlacement: input.openPlacement } : {}) + })) + if (!listed) { + tabs.push({ + type: 'agent-session', + id, + title: defaultAgentChatLabel(input.agent), + sessionId: input.sessionId, + agent: input.agent, + isActive: input.activate, + ...(input.openPlacement ? { openPlacement: input.openPlacement } : {}) + }) + } + const priorGroups = existing?.tabGroups ?? [ + { + id: getHeadlessMobileSessionGroupId(input.workspaceId), + activeTabId: existing?.activeTabId ?? null, + tabOrder: [] + } + ] + const placement = input.openPlacement?.placement + const listedGroup = priorGroups.find((group) => group.tabOrder.includes(id)) + const anchorGroup = priorGroups.find((group) => + group.tabOrder.includes(placement?.afterTabId ?? '') + ) + const groupId = + listedGroup?.id ?? + priorGroups.find((group) => group.id === placement?.groupId)?.id ?? + anchorGroup?.id ?? + priorGroups.find((group) => group.id === existing?.activeGroupId)?.id ?? + priorGroups[0]?.id + const tabGroups = priorGroups.map((group) => { + if (group.id !== groupId) { + return group + } + let tabOrder = group.tabOrder + if (!listedGroup) { + const after = placement?.afterTabId + if (after && tabOrder.includes(after)) { + tabOrder = insertTabIdIntoOrder(tabOrder, tabs, id, false, after) + } else if (placement?.index !== undefined) { + const preceding = tabOrder[Math.max(0, Math.min(placement.index, tabOrder.length)) - 1] + tabOrder = preceding + ? insertTabIdIntoOrder(tabOrder, tabs, id, false, preceding) + : partitionPinnedTabOrder([...tabOrder, id], tabs, id) + } else { + tabOrder = [...tabOrder, id] + } + } + return { ...group, tabOrder, activeTabId: input.activate ? id : group.activeTabId } + }) + return { + ...existing, + worktree: input.workspaceId, + publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: input.activate + ? (groupId ?? null) + : (existing?.activeGroupId ?? groupId ?? null), + activeTabId: input.activate ? id : (existing?.activeTabId ?? null), + activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), + tabGroups, + tabs + } +} diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index a51fa220a31e..6381bdc1eff6 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -1,41 +1,22 @@ +import { withSelectedChatTab } from '../agent-session/agent-session-tab-layout' import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileSessionTabsChangedMethods } from './session-tabs-change-publication' -import { defaultAgentChatLabel } from '../../../shared/agent-session/agent-session-chat-label' -import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './structured-session-create-support' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { restoredChatTabChanges } from './restored-chat-tab-changes' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import type { - RuntimeMobileSessionAgentTab, - RuntimeMobileSessionTabsSnapshot, - RuntimeRepoSearchRefs -} from '../../../shared/runtime-types' -import { getHeadlessMobileSessionGroupId } from '../mobile-session-layout-projection' -import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from '../orca-runtime-postlude' -import type { Repo } from '../../../shared/repo-types' -import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' -import { - getLocalProjectGhExecOptions, - type LocalProjectGhExecOptions -} from '../../project-runtime-git-options' -import { - agentLaunchPlatformForScope, - newWorktreeLaunchScope, - resolveAgentLaunchRuntime, - resolveStartupAgentLaunchRuntime -} from '../agent-launch-scope-runtime' -import type { TuiAgent } from '../../../shared/tui-agent' -import type { AgentLaunchRuntime } from '../../../shared/agent-launch-runtime' -import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' -import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' -import type { PtyProcessInspection } from '../../providers/pty-process-inspection' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { + isSessionTabSelectionRequest, + type SessionTabOpenPlacement +} from '../../../shared/session-tab-placement' +import { projectStructuredSessionTab } from './structured-session-tab-projection' +import { OrcaRuntimeWithStructuredSessionLaunchRuntime } from './structured-session-launch-runtime' export type AgentLaunchScope = Parameters< OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce['getAgentLaunchPlatformForWorkspace'] >[0] -export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { +export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithStructuredSessionLaunchRuntime { protected abstract notifyMobileSessionTabSnapshots( ...args: Parameters ): ReturnType @@ -93,11 +74,26 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten } } + captureSessionSwitchSelection( + _worktreeId?: string, + viewerSelection?: string, + deferAcceptance = false + ) { + // A window request is transported to its viewer; it grants no host-view authority. + return viewerSelection !== undefined + ? Object.assign((_consume = false) => false, { accept: () => {} }) + : this.clientSessionTabSelections.captureHostSelection(deferAcceptance) + } + async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string agent: StructuredAgentId activate: boolean + check?: () => void + selectionCurrent?: (consume?: boolean) => boolean + viewerSelection?: string + focusWindow?: boolean notify?: boolean /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string @@ -105,10 +101,18 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten placedByWindow?: boolean /** Where the creating paired client opens the chat. */ /** A ChatTabPlacement, typed in place: this file is at its line limit. */ - placement?: { groupId?: string; afterTabId?: string } + placement?: { groupId?: string; afterTabId?: string; index?: number } + openPlacement?: SessionTabOpenPlacement }): Promise { const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { + input.check?.() + // First membership includes placement, even while visibility or the durable queue is awaited. + if (input.openPlacement) { + this.projectStructuredAgentSessionTab({ ...input, activate: false, notify: false }) + } + if (input.check && host) { + await host.deps.store.setSessionTabVisibility(input.sessionId, true, input.tabId, input.check) + } else if (typeof host?.setSessionTabVisibility === 'function') { // Restore-index failures (newer records/disk errors) are reported without preventing tab open. await host .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) @@ -121,10 +125,65 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten }) } // Window creates retain the window's own placement decision. - if (!(ORCA_LAYOUT_OWNER_IS_MAIN && input.placedByWindow)) { + input.check?.() + if (input.check && (ORCA_LAYOUT_OWNER_IS_MAIN || !this.getAvailableAuthoritativeWindow())) { + const check = input.check + const applied = await this.queueChatTabChanges( + () => [ + { + worktreeId: input.workspaceId, + change: { open: { ...input, tabId: input.tabId ?? `agent-session:${input.sessionId}` } } + } + ], + () => { + check() + return true + } + ) + if (!applied) { + throw new Error('tab_not_found') + } + } else if (!(ORCA_LAYOUT_OWNER_IS_MAIN && input.placedByWindow)) { this.saveWindowlessAgentSessionTab(input.workspaceId, input) } - this.projectStructuredAgentSessionTab(input) + input.check?.() + const viewerRequest = isSessionTabSelectionRequest(input.viewerSelection) + const activate = input.activate && !viewerRequest && (input.selectionCurrent?.() ?? true) + if (activate) { + input.check?.() + input.selectionCurrent?.(true) + } + this.projectStructuredAgentSessionTab({ + ...input, + activate, + ...(input.openPlacement + ? { + openPlacement: { + ...input.openPlacement, + activate: viewerRequest ? input.openPlacement.activate : activate + } + } + : {}) + }) + if (activate) { + input.check?.() + this.commitWindowlessChatSession(input.workspaceId, (session) => + withSelectedChatTab( + this.getWorkspaceSessionHostIdForWorktree(input.workspaceId), + session, + input.workspaceId, + input.sessionId + ) + ) + } + if ((activate || (viewerRequest && input.activate)) && input.focusWindow !== false) { + input.check?.() + this.notifier?.focusEditorTab?.( + input.tabId ?? host?.getSessionTabId(input.sessionId) ?? `agent-session:${input.sessionId}`, + input.workspaceId, + input.viewerSelection + ) + } } /** The runtime's own snapshot of a chat tab. Records nothing: the caller owns the store write. */ @@ -134,196 +193,23 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten agent: StructuredAgentId activate: boolean notify?: boolean + openPlacement?: SessionTabOpenPlacement }): void { - const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) - const id = `agent-session:${input.sessionId}` - if (existing?.tabs.some((tab) => tab.id === id)) { - // Background republish does not write/emit; healing missing client mirrors needs activate or explicit republish. - if (!input.activate) { - return - } - const priorGroups = existing.tabGroups ?? [] - const groupId = - priorGroups.find((group) => group.tabOrder.includes(id))?.id ?? - (priorGroups.some((group) => group.id === existing.activeGroupId) - ? existing.activeGroupId - : priorGroups[0]?.id) - const snapshot: RuntimeMobileSessionTabsSnapshot = { - ...existing, - snapshotVersion: existing.snapshotVersion + 1, - activeGroupId: groupId ?? existing.activeGroupId, - activeTabId: id, - activeTabType: 'agent-session', - tabGroups: priorGroups.map((group) => - group.id === groupId ? { ...group, activeTabId: id } : group - ), - tabs: existing.tabs.map((tab) => ({ ...tab, isActive: tab.id === id })) - } - const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot) - if (input.notify !== false) { - this.emitMobileSessionTabsSnapshot(stored) - } + const next = projectStructuredSessionTab( + this.mobileSessionTabsByWorktree.get(input.workspaceId), + input + ) + if (!next) { return } - const tab: RuntimeMobileSessionAgentTab = { - type: 'agent-session', - id, - title: defaultAgentChatLabel(input.agent), - sessionId: input.sessionId, - agent: input.agent, - isActive: input.activate - } - const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ - ...candidate, - isActive: input.activate ? false : candidate.isActive - })) - tabs.push(tab) - const priorGroups = existing?.tabGroups ?? [ - { - id: getHeadlessMobileSessionGroupId(input.workspaceId), - activeTabId: existing?.activeTabId ?? null, - tabOrder: [] - } - ] - // Why: the window can list the chat before main restores its row; keep the place it gave it. - const listingGroupId = priorGroups.find((group) => group.tabOrder.includes(id))?.id - const groupId = - listingGroupId ?? - (priorGroups.some((group) => group.id === existing?.activeGroupId) - ? existing!.activeGroupId! - : priorGroups[0]!.id) - const tabGroups = priorGroups.map((group) => - group.id === groupId - ? { - ...group, - activeTabId: input.activate ? id : group.activeTabId, - tabOrder: listingGroupId ? group.tabOrder : [...group.tabOrder, id] - } - : group - ) - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: input.workspaceId, - publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), - activeTabId: input.activate ? id : (existing?.activeTabId ?? null), - activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), - tabGroups, - ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), - tabs + const stored = this.storeMobileSessionSnapshot(input.workspaceId, next) + if (input.openPlacement) { + this.markPaneLiveTitlesDirty() } - const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot) if (input.notify !== false) { this.emitMobileSessionTabsSnapshot(stored) } } - async inspectTerminalProcess( - terminalSelector: string, - options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } - ): Promise { - const leaf = this.resolveLiveLeafForHandle(terminalSelector) - if (!leaf?.ptyId || !this.ptyController) { - throw new Error('terminal_gone') - } - if (this.ptyController.inspectProcess) { - // Preserve one-argument unfenced calls; providers may use arity to select legacy versus fenced inspection. - const inspection = - options === undefined - ? await this.ptyController.inspectProcess(leaf.ptyId) - : await this.ptyController.inspectProcess(leaf.ptyId, options) - const evidence = inspection.foregroundProcessEvidence - // The runtime handle is the request identity on this wire; keep the - // host-owned leaf PTY id out of the client-facing comparison. - const relayPtyId = parseAppSshPtyId(leaf.ptyId)?.relayPtyId - const evidenceBelongsToLeaf = - evidence !== undefined && (evidence.ptyId === leaf.ptyId || evidence.ptyId === relayPtyId) - return evidenceBelongsToLeaf - ? { ...inspection, foregroundProcessEvidence: { ...evidence, ptyId: terminalSelector } } - : inspection - } - const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) - const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false - return { foregroundProcess, hasChildProcesses } - } - - async searchRepoRefs( - repoSelector: string, - query: string, - limit = DEFAULT_REPO_SEARCH_REFS_LIMIT, - includeQualifiedRefs = true - ): Promise { - return this.repositoryRefQueries.search(repoSelector, query, limit, includeQualifiedRefs) - } - - protected async resolveHostedReviewTarget(args: { - repoSelector: string - worktreeSelector?: string - }): Promise<{ repo: Repo; repoPath: string }> { - const repo = await this.resolveRepoSelector(args.repoSelector) - if (!args.worktreeSelector) { - return { repo, repoPath: repo.path } - } - - const worktree = await this.resolveGitWorktreeSelector(args.worktreeSelector) - if (worktree.repoId !== repo.id) { - throw new Error('Access denied: worktree does not belong to repository') - } - return { repo, repoPath: worktree.path } - } - - protected getHostedReviewExecutionOptions( - repo: Repo, - admissionTier?: GitAdmissionTier - ): - | { localGitExecOptions: LocalProjectGhExecOptions & { admissionTier?: GitAdmissionTier } } - | undefined { - const localGitOptions = { - ...this.getLocalGitExecutionOptionArgs(repo)[0], - ...(admissionTier && { admissionTier }) - } - return Object.keys(localGitOptions).length > 0 - ? { localGitExecOptions: localGitOptions } - : undefined - } - - protected getLocalGitExecutionOptionArgs(repo: Repo): [] | [LocalProjectGhExecOptions] { - const localGitOptions = getLocalProjectGhExecOptions(this.requireStore(), repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] - } - - protected getAgentLaunchPlatformForWorkspace( - scope: Pick & { - connectionId?: string | null - } - ): NodeJS.Platform { - // Orchestration passes a repo-only scope; the platform never reads its id or folder. - return agentLaunchPlatformForScope(this.requireStore(), { - id: '', - folderWorkspace: null, - ...scope, - connectionId: scope.connectionId ?? null - }) - } - - protected resolveAgentLaunchRuntime( - scope: TerminalWorkspaceLaunchScope, - agent: TuiAgent | undefined - ): AgentLaunchRuntime { - return resolveAgentLaunchRuntime(this.requireStore(), scope, undefined, agent) - } - /** A startup's fact: the workspace it starts in, else the new worktree a create is making. */ - protected resolveStartupAgentLaunchRuntime( - repo: Repo, - agent: TuiAgent, - scope?: TerminalWorkspaceLaunchScope - ): ReturnType { - const store = this.requireStore() - return resolveStartupAgentLaunchRuntime( - store, - scope ?? newWorktreeLaunchScope(repo, store.getSettings()), - agent - ) - } } diff --git a/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts b/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts new file mode 100644 index 000000000000..986c2aeff059 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts @@ -0,0 +1,65 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { TerminalLaunchResult } from '../../../shared/agent-session-resume' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { listStructuredProviderSessionOwnership } from '../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { + keysMayMatch, + terminalConversationKey +} from '../../native-chat/structured-conversation-key' + +/** Presentation only: a delayed successful spawn must never close a newer native child. */ +export async function retireChatForAdmittedTerminalLaunch( + runtime: { retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean } | undefined, + args: { resumeOrigin?: unknown; worktreeId?: unknown }, + result: TerminalLaunchResult & { isReattach?: boolean } +): Promise { + const decision = result.savedResumeDecision + if ( + args.resumeOrigin !== 'switch' || + result.isReattach || + decision?.verdict !== 'admitted' || + result.agentResumeRefused || + result.agentResumeUnavailable + ) { + return + } + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + const key = terminalConversationKey(decision.agent, decision.providerSession.id) + const holders = listStructuredProviderSessionOwnership( + host.deps.store.listConversations() + ).filter((holding) => holding.role === 'head' && keysMayMatch(holding.key, key)) + for (const sessionId of new Set(holders.map((holding) => holding.sessionId))) { + if (host.switchExecuting(sessionId)) { + continue + } + const record = host.deps.store.getRecord(sessionId) + if ( + record?.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.workspaceId !== args.worktreeId + ) { + continue + } + try { + await host.setSessionTabVisibility(sessionId, false) + } catch (error) { + // The terminal is admitted regardless of a failed visibility write; never stop another child. + host.deps.logger.warn('recording a switched chat tab retirement failed', { + scope: 'switch-tab-retirement', + sessionId: sessionId, + error + }) + } + try { + runtime?.retireStructuredAgentSessionTabFromSnapshot(sessionId) + } catch (error) { + host.deps.logger.warn('publishing a switched chat tab retirement failed', { + scope: 'switch-tab-retirement', + sessionId: sessionId, + error + }) + } + } +} diff --git a/src/main/runtime/session-tabs/terminal-surface-close-commit.ts b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts new file mode 100644 index 000000000000..aad34940e4b3 --- /dev/null +++ b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts @@ -0,0 +1,160 @@ +import type { RuntimeMobileSessionTerminalTab } from '../../../shared/runtime-types' +import { + isRetainedTerminalSurfaceIdentity, + type TerminalSurfaceCloseIdentity +} from '../../../shared/terminal-tab-close' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WindowLayoutCommand, LayoutCommandRunOptions } from '../runtime-layout-apply' +import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './terminal-surface-retirements' +import { captureAcknowledgedTerminalTabRetirement } from '../workspace-session-terminal-tab-retirement-identity' +import { closeLeafOrTab } from '../../persistence/terminal-topology/terminal-topology-commit' +import { + resolveTerminalCloseTarget, + type PaneCloseResolution, + type RendererTerminalClose, + captureTerminalCloseRequest, + type TerminalSurfaceCloseOptions +} from '../terminal-surface-close' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' + +export abstract class OrcaRuntimeWithTerminalSurfaceCloseCommit extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { + abstract applyLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise + /** The desktop renderer's close intent: it already guarded, removed and killed; this only reports. */ + async closeTerminalSurfaceFromRenderer({ worktreeId, target, reason }: RendererTerminalClose) { + const options = { allowMissing: true, force: true, closedByLayoutOwner: true, reason } + await this.closeTerminalSurface(worktreeId, target, options) + } + + protected originalTerminalExecutionMatches(source: TerminalSurfaceCloseIdentity): boolean { + const current = source.ptyId ? this.ptysById.get(source.ptyId) : null + const replacing = source.ptyId ? this.pendingPtyHandleReplacementFences.get(source.ptyId) : null + const leaf = this.leaves.get(this.getLeafKey(source.tabId, source.leafId)) + return ( + (!current || current.incarnationId === source.incarnationId) && + (!leaf || + (leaf.worktreeId === source.worktreeId && (!leaf.ptyId || leaf.ptyId === source.ptyId))) && + isRetainedTerminalSurfaceIdentity(source, { + ...source, + worktreeId: current?.worktreeId ?? source.worktreeId, + ptyId: current?.ptyId ?? null, + incarnationId: replacing?.incarnationId ?? current?.incarnationId ?? null + }) + ) + } + + /** Resolves a close main started against the copy of the tab's panes its layout owner holds. */ + protected resolveTerminalCloseTarget( + worktreeId: string, + target: TerminalSurfaceCloseTarget + ): PaneCloseResolution | 'tab' { + const graphLeafIds: string[] = [] + for (const leaf of this.leaves.values()) { + if (leaf.tabId === target.tabId) { + graphLeafIds.push(leaf.leafId) + } + } + return resolveTerminalCloseTarget(target, { + rendererListsTab: this.tabs.has(target.tabId), + snapshotRows: (this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs ?? []).filter( + (row): row is RuntimeMobileSessionTerminalTab => + row.type === 'terminal' && row.parentTabId === target.tabId + ), + graphLeafIds, + sessionLayout: + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId?.[target.tabId] + }) + } + + protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { + const originalHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) + return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { + const resolvedHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) + const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) + // Emptying the last tab may reroute the worktree to its catalog host. + const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ? resolvedHostId + : originalHostId + return { + hostId, + session: this.store?.getWorkspaceSession?.(hostId) ?? null, + snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), + incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId + } + }) + } + + /** + * The one close transaction every explicit terminal close reaches: durably commits the membership + * removal in the owning host's partition. Callers publish and kill afterwards. + */ + protected async closeTerminalSurface( + worktreeId: string, + target: TerminalSurfaceCloseTarget, + options: TerminalSurfaceCloseOptions = {} + ): Promise { + const store = this.store + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { + throw new Error('workspace_session_unavailable') + } + // Why: a tab its layout owner already removed has no newer owner; refusing would only strand it. + const acknowledgeTabRetirement = + target.kind === 'tab' && !options.closedByLayoutOwner + ? this.captureTerminalTabRetirement(worktreeId, target.tabId) + : null + let ptyIdsToKill: string[] = [] + let refusal: Error | undefined + const original = options.originalPresentation + const hostIds = () => + original + ? [ + ...new Set([ + ...original.holders.map((holder) => holder.hostId), + ...this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId).filter((id) => + this.store + ?.getWorkspaceSession?.(id) + ?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === target.tabId) + ) + ]) + ] + : this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId) + const fenced = acknowledgeTabRetirement !== null && !original + try { + refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId, + target, + options, + ...captureTerminalCloseRequest( + worktreeId, + target, + hostIds(), + { getWorkspaceSession: (hostId) => store.getWorkspaceSession!(hostId) }, + fenced + ), + ownerMatches: () => + original + ? this.originalTerminalExecutionMatches(original.identity) + : !acknowledgeTabRetirement || acknowledgeTabRetirement().matches, + hostIds, + getSession: (hostId) => store.getWorkspaceSession!(hostId), + setSession: (session, hostId) => store.setWorkspaceSession!(session, hostId), + onClosed: (closedPtyIds) => { + ptyIdsToKill = closedPtyIds + } + }) + ) + } catch (error) { + console.error('[runtime] failed to persist terminal close:', error) + if (original) { + throw error + } + } + if (refusal) { + throw refusal + } + return ptyIdsToKill + } +} diff --git a/src/main/runtime/session-tabs/terminal-switch-close-capture.ts b/src/main/runtime/session-tabs/terminal-switch-close-capture.ts new file mode 100644 index 000000000000..607d63cf3a06 --- /dev/null +++ b/src/main/runtime/session-tabs/terminal-switch-close-capture.ts @@ -0,0 +1,107 @@ +import { loadChatTabLayout, type ChatTabPlacement } from '../agent-session/agent-session-tab-layout' +import { windowListsTab } from '../terminal/surface/main-terminal-surfaces' +import { isOriginalTerminalHandle } from '../workspace-session-terminal-tab-retirement-identity' +import { + matchesTerminalSurfaceCapture, + type TerminalSurfaceCloseCapture +} from '../../../shared/terminal-tab-close' +import { OrcaRuntimeWithExplicitTerminalTabClose } from './explicit-terminal-tab-close' +export abstract class OrcaRuntimeWithTerminalSwitchCloseCapture extends OrcaRuntimeWithExplicitTerminalTabClose { + captureTerminalSwitchClose( + handle: string, + incarnation: string, + originalPresentation: TerminalSurfaceCloseCapture, + replacement = true + ) { + const source = originalPresentation.identity + const record = this.handles.get(handle) + const currentPty = source.ptyId ? this.ptysById.get(source.ptyId) : null + const leaf = this.leaves.get(this.getLeafKey(source.tabId, source.leafId)) + if ( + !source.ptyId || + !incarnation || + !isOriginalTerminalHandle({ + runtimeId: this.runtimeId, + record, + currentPty, + paneOfPty: (current) => this.paneOfPty(current), + leaf, + source, + incarnation + }) || + !matchesTerminalSurfaceCapture( + originalPresentation, + this.getWorkspaceSessionHostIdsForTab(source.worktreeId, source.tabId), + (hostId) => this.store?.getWorkspaceSession?.(hostId) + ) + ) { + throw new Error('terminal_handle_stale') + } + const readPlacement = (): ChatTabPlacement | undefined => { + const session = this.getWorkspaceSessionForWorktree(source.worktreeId) + if (!session) { + return undefined + } + const { loaded } = loadChatTabLayout( + this.getWorkspaceSessionHostIdForWorktree(source.worktreeId), + session, + source.worktreeId + ) + const group = loaded.layout.workspaces[source.worktreeId]?.groups.find((row) => + row.tabOrder.includes(source.tabId) + ) + return group + ? { + groupId: group.id, + afterTabId: source.tabId, + index: group.tabOrder.indexOf(source.tabId) + } + : undefined + } + const originalPlacement = readPlacement() + const addressedPty = source.ptyId + const rendererOwnsPresentation = windowListsTab(this, this.tabs, source.tabId) + const proof = { + parentTabId: source.tabId, + leafId: source.leafId, + ptyId: source.ptyId, + terminal: handle, + incarnationId: incarnation + } + return { + source, + placement: () => + matchesTerminalSurfaceCapture( + originalPresentation, + this.getWorkspaceSessionHostIdsForTab(source.worktreeId, source.tabId), + (hostId) => this.store?.getWorkspaceSession?.(hostId) + ) + ? (readPlacement() ?? originalPlacement) + : undefined, + stop: async () => { + const stop = await this.stopExplicitlyClosedTabPtys( + [addressedPty], + addressedPty, + incarnation + ) + if (!stop.stopped) { + throw new Error('terminal_close_unverifiable') + } + }, + retire: async () => { + const outcome = await this.closeMobileSessionTab(`id:${source.worktreeId}`, source.tabId, { + originalPresentation, + rendererOwnsPresentation, + localPtyTeardownOwnedExternally: true, + force: replacement, + retirementProofs: [proof] + }) + if (outcome.refused) { + throw new Error('terminal_pane_close_failed') + } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return this.describeTerminalClose(handle, source.tabId, source.ptyId, { stopped: true }) + } + } + } +} diff --git a/src/main/runtime/structured-agent-cli-writers.ts b/src/main/runtime/structured-agent-cli-writers.ts index 5805f7d1eb6a..f9685d45acf9 100644 --- a/src/main/runtime/structured-agent-cli-writers.ts +++ b/src/main/runtime/structured-agent-cli-writers.ts @@ -5,7 +5,10 @@ import { chatAgentsShareConversations, CLI_CONVERSATION_ADOPTING_AGENTS } from '../../shared/structured-agent-cli-conversation-adoption' -import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalError, + isAgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-observation' import { getFreshShellForegroundSnapshot } from '../../shared/process-table-snapshot-reader' import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' @@ -107,7 +110,14 @@ export async function assertCliConversationWritersExited( } try { if (source) { - await censusPane(() => host.terminal(source), source.worktreeId, true) + try { + await censusPane(() => host.terminal(source), source.worktreeId, true) + } catch { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere', + source: true + }) + } } const savedCandidates = () => host @@ -255,7 +265,10 @@ export async function assertCliConversationWritersExited( ) { throw refuse() } - } catch { + } catch (error) { + if (isAgentSessionRefusalError(error)) { + throw error + } throw refuse() } } diff --git a/src/main/runtime/structured-agent-session-claude-kill-evidence.test.ts b/src/main/runtime/structured-agent-session-claude-kill-evidence.test.ts index 632b19f36173..037f0e2d62e0 100644 --- a/src/main/runtime/structured-agent-session-claude-kill-evidence.test.ts +++ b/src/main/runtime/structured-agent-session-claude-kill-evidence.test.ts @@ -5,6 +5,9 @@ import { SUPERVISED_GRACEFUL_EXIT_MS } from '../claude/claude-child-exit-proof-l import { AgentSessionRecordStore } from './agent-session-record-store' import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { nativeSwitchPhysicalFixture } from './structured-native-switch-physical.test-fixture' +import { agentSessionSwitchDeadline } from '../../shared/agent-session-switch-execution' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' const os = vi.hoisted(() => ({ capture: vi.fn(), @@ -107,6 +110,195 @@ it('preserves actual TRUE writer-close through published close, join, and a heal expect(fixture.record().lease).toEqual(replacement) }) +it('joins actual idle Claude root exit before the ordinary switch destination writer', async () => { + await attach() + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + try { + fixture.permitKill.add(0) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const operation = switching.run() + await forceTime() + await fixture.drain() + killEvidence() + expect(switching.spawn).not.toHaveBeenCalled() + expect(fixture.connections[0]!.exitVerdict.root).toBe('live') + const kills = fixture.signal.mock.calls.length + fixture.observedExit(0) + await vi.advanceTimersByTimeAsync(0) + expect(await operation).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.signal.mock.calls).toHaveLength(kills) + expect(fixture.prompts).toEqual([]) + } finally { + switching.dispose() + } +}) + +it('retains every unresolved ordinary generation across eviction and only joins its exact root', async () => { + await attach() + fixture.permitKill.add(0) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const close = fixture.host.close(SESSION, 'evict') + await forceTime() + await close + expect(fixture.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + vi.useRealTimers() + await fixture.healthy() + fixture.permitKill.add(1) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const closeNext = fixture.host.close(SESSION, 'evict') + await forceTime() + await closeNext + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + try { + const operation = switching.run() + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + const kills = fixture.signal.mock.calls.length + fixture.observedExit(1) + await vi.advanceTimersByTimeAsync(0) + expect(switching.spawn).not.toHaveBeenCalled() + fixture.observedExit(0) + await vi.advanceTimersByTimeAsync(0) + expect(await operation).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.signal.mock.calls).toHaveLength(kills) + expect(fixture.prompts).toHaveLength(1) + } finally { + switching.dispose() + } +}) + +it('joins an unpublished failed ensure that enters while an older physical source is pending', async () => { + await attach() + fixture.permitKill.add(0) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const closing = fixture.host.close(SESSION, 'evict') + await forceTime() + await closing + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + const identity = Promise.withResolvers() + try { + const operation = switching.run().catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + const admitted = fixture.host.deps.store.admittedSendCount(SESSION) + const command = fixture.host.switchGeneration(SESSION) + expect(await fixture.host.attach(fixture.caller, fixture.params())).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } } + }) + expect(fixture.connections).toHaveLength(1) + const record = fixture.record() + const params = attachParamsForRecord(record, { + clientOperationId: fixture.params().envelope.clientOperationId, + expectedRuntimeFence: record.lease.runtimeFence + }) + fixture.heldIdentity.set(9100100, identity.promise) + fixture.permitKill.add(1) + // agentSession.ensure delegates to this ordinary serialized attach owner. + const ensuring = fixture.host.attach(fixture.caller, params).catch((error: unknown) => error) + await vi.waitFor(() => expect(fixture.connections).toHaveLength(2)) + fixture.children[1]!.stdin.emit('error', new Error('ordinary ensure startup transport failed')) + await forceTime() + identity.resolve() + await forceTime() + expect(await ensuring).toMatchObject({ ok: false }) + await fixture.drain() + killEvidence() + expect(fixture.connections[1]!.exitVerdict).toMatchObject({ + root: 'live', + killDelivered: true + }) + expect(fixture.host.deps.store.admittedSendCount(SESSION)).toBe(admitted) + expect(fixture.host.switchGeneration(SESSION)).toBe(command) + expect(fixture.host.collaboratorsForTests().sessions.get(SESSION)?.child).toBeFalsy() + const kills = fixture.signal.mock.calls.length + fixture.observedExit(0) + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + fixture.observedExit(1) + await vi.advanceTimersByTimeAsync(0) + expect(await operation).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.signal.mock.calls).toHaveLength(kills) + expect(fixture.prompts).toEqual([]) + } finally { + identity.resolve() + switching.dispose() + } +}) + +it.each(['deadline', 'new-send'] as const)( + 'ends an unproven switch on %s without blocking the ordinary next send or replaying on late exit', + async (end) => { + await attach() + fixture.permitKill.add(0) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const closing = fixture.host.close(SESSION, 'evict') + await forceTime() + await closing + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + try { + const operation = switching + .run(end === 'deadline' ? 100 : 120_000) + .catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + if (end === 'deadline') { + await vi.advanceTimersByTimeAsync(101) + expect(await operation).toMatchObject({ + refusal: { code: 'agent_session_operation_expired' } + }) + expect((await fixture.host.revealSession(SESSION)).readable).toBe(true) + } + vi.useRealTimers() + await fixture.healthy('New user work is never held behind source exit.') + if (end === 'new-send') { + expect(await operation).toMatchObject({ + refusal: { details: { reason: 'messagesUnsettled' } } + }) + } + const successor = fixture.record().lease + fixture.observedExit(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + expect(fixture.record().lease).toEqual(successor) + fixture.observedExit(1) + await fixture.drain() + expect(await switching.run()).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.prompts).toHaveLength(1) + } finally { + switching.dispose() + } + } +) + it('settles a real failed acquisition with exact kill evidence and rejects dispatch before a healthy wake', async () => { await attach() fixture.observedExit(0) @@ -221,6 +413,22 @@ it.each(['identity', 'spawn-return'] as const)( expect(report.deathEvidenceKind).toBe('kill-delivered') await fixture.drain() killEvidence() + const deadline = agentSessionSwitchDeadline() + try { + let proved = false + const stopping = fixture.host.stopForSwitch(SESSION, fixture.caller, deadline).then(() => { + proved = true + }) + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(proved).toBe(false) + fixture.observedExit(1) + await vi.advanceTimersByTimeAsync(0) + await stopping + expect(proved).toBe(true) + } finally { + deadline.dispose() + } if (!pending.ok) { throw new Error('send refused') } @@ -285,6 +493,14 @@ it('keeps undelivered close retryable until an actual root event, without invent expect(fixture.delivered.size).toBe(0) vi.useRealTimers() expect(await fixture.adapter.closeSession!(SESSION)).toBe(true) + const deadline = agentSessionSwitchDeadline() + try { + expect(await fixture.host.stopForSwitch(SESSION, fixture.caller, deadline)).toBeTypeOf( + 'function' + ) + } finally { + deadline.dispose() + } await fixture.healthy() }) @@ -305,6 +521,40 @@ it('keeps an actual processless failure and an absent-session close distinct fro await fixture.healthy() }) +it('settles pre-adapter startup failure without holding a later explicit switch', async () => { + await attach() + fixture.observedExit(0) + await fixture.host.close(SESSION, 'evict') + const acquire = vi.spyOn(fixture.host.deps.adapter, 'acquire') + const track = vi + .spyOn(fixture.host.collaboratorsForTests().runtimeState.startupAttempts, 'track') + .mockImplementationOnce(() => { + throw new Error('fixture startup registration failed before adapter entry') + }) + const record = fixture.record() + const params = attachParamsForRecord(record, { + clientOperationId: fixture.params().envelope.clientOperationId, + expectedRuntimeFence: record.lease.runtimeFence + }) + expect(await fixture.host.attach(fixture.caller, params)).toMatchObject({ ok: false }) + expect(track).toHaveBeenCalledOnce() + expect(acquire).not.toHaveBeenCalled() + expect(fixture.children).toHaveLength(1) + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + try { + expect(await switching.run()).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.delivered.size).toBe(0) + } finally { + switching.dispose() + } +}) + it('preserves known delivery when the published lifecycle callback fails', async () => { await attach() const fault = new Error('fixture lifecycle callback failure') diff --git a/src/main/runtime/structured-agent-session-codex-stop-evidence.test.ts b/src/main/runtime/structured-agent-session-codex-stop-evidence.test.ts index 91d26edae51c..a2d5e93a3bb9 100644 --- a/src/main/runtime/structured-agent-session-codex-stop-evidence.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stop-evidence.test.ts @@ -25,6 +25,7 @@ import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import type { AgentSessionStopOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter-stop' import { agentVerdictDisplayMark } from '../../shared/agent-main-agent-verdict' import { activeProviderContext } from '../../shared/agent-session-provider-context' +import { nativeSwitchPhysicalFixture } from './structured-native-switch-physical.test-fixture' const os = vi.hoisted(() => ({ capture: vi.fn<(pid: number) => Promise>(), @@ -267,6 +268,37 @@ const warnings = (items: Awaited (item) => item.body.kind === 'status' && item.body.failure?.kind === 'cancelUnconfirmed' ) +it('joins the exact Codex root after ordinary replying Stop before opening one terminal', async () => { + await fixture.start() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + expect(await fixture.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await forceTime() + await fixture.drain() + expect(fixture.record().lease.deathEvidence?.kind).toBe('kill-delivered') + expect(fixture.connections[0]!.closeResult?.root).toBe('live') + const switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'codex' + ) + try { + const operation = switching.run() + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + expect(switching.spawn).not.toHaveBeenCalled() + const kills = fixture.signal.mock.calls.length + fixture.children[0]!.emit('exit', 0, null) + await vi.advanceTimersByTimeAsync(0) + expect(await operation).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.signal.mock.calls).toHaveLength(kills) + expect(fixture.connections).toHaveLength(1) + } finally { + switching.dispose() + } +}) + it.each(['fresh', 'cached-before-ack', 'retired-before-join'] as const)( 'uses exact delivered writer evidence for %s Stop without inventing root exit', async (mode) => { diff --git a/src/main/runtime/structured-agent-session-host-assembly.ts b/src/main/runtime/structured-agent-session-host-assembly.ts index 5e8fea9b53b0..e02b856fc8d5 100644 --- a/src/main/runtime/structured-agent-session-host-assembly.ts +++ b/src/main/runtime/structured-agent-session-host-assembly.ts @@ -74,6 +74,9 @@ export async function installOnJournal( ...(deps.conversationNaming ? { conversationNaming: deps.conversationNaming } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), + ...(deps.captureSessionSwitchStop + ? { captureSessionSwitchStop: deps.captureSessionSwitchStop } + : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(deps.assertNoTerminalWriter ? { assertNoTerminalWriter: deps.assertNoTerminalWriter } : {}), ...(await modelCatalogHostDeps({ diff --git a/src/main/runtime/structured-agent-session-physical-exit-scaling.test.ts b/src/main/runtime/structured-agent-session-physical-exit-scaling.test.ts new file mode 100644 index 000000000000..ddcf0a40a225 --- /dev/null +++ b/src/main/runtime/structured-agent-session-physical-exit-scaling.test.ts @@ -0,0 +1,223 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { claudePipedFixture, SESSION } from './structured-claude-piped-test-support' +import { nativeSwitchPhysicalFixture } from './structured-native-switch-physical.test-fixture' +import { SUPERVISED_GRACEFUL_EXIT_MS } from '../claude/claude-child-exit-proof-ladder' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' +import { hostTestAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import * as settlements from '../../shared/promise-settlement-waiters' + +vi.mock('../pty-descendant-termination', () => ({ + captureDescendantSnapshot: async (pid: number) => ({ + rootPgid: pid, + capturedAtMs: 1, + root: { pid, startedAt: 'Mon Jan 1 00:00:00 2026' }, + descendants: [{ pid: pid + 1, ppid: pid, pgid: pid + 1, startedAt: 'Mon Jan 1 00:00:00 2026' }] + }) +})) +vi.mock('../pty-descendant-exit-verification', () => ({ + terminateDescendantSnapshotWithVerdict: async () => 'unverifiable' +})) +vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ + recordSelfInitiatedTreeKill: vi.fn() +})) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +it.each([10, 100])( + 'bounds actual physical-owner joins and expired switch retries at %i acquisitions', + async (count) => { + const fixture = await claudePipedFixture() + const flights = fixture.host.collaboratorsForTests().runtimeState.startFlights + const owners = new Map number }>() + const observers = new Map() + const visited: unknown[] = [] + const removed: unknown[] = [] + let currentChecks = 0 + const observe = flights.observePhysicalExit.bind(flights) + vi.spyOn(flights, 'observePhysicalExit').mockImplementation((id, exited, covered) => { + const then = vi.spyOn(exited, 'then') + owners.set(exited, { sessionId: id, reactions: () => then.mock.calls.length }) + return observe(id, exited, covered) + }) + const shared = settlements.sharedPromiseSettlementWaiters + vi.spyOn(settlements, 'sharedPromiseSettlementWaiters').mockImplementation( + (promise: Promise) => { + const observer = shared(promise) + if (owners.has(promise)) { + observers.set(promise, observer) + } + return observer + } + ) + const values = Set.prototype.values + function observedValues(this: Set) { + const iterator = values.call(this) + const next = iterator.next.bind(iterator) + iterator.next = (...args) => { + const result = next(...args) + if (!result.done && owners.has(result.value)) { + visited.push(result.value) + } + return result + } + return iterator + } + vi.spyOn(Set.prototype, 'values').mockImplementation(observedValues) + vi.spyOn(Set.prototype, Symbol.iterator).mockImplementation(observedValues) + const deleteEntry = Set.prototype.delete + vi.spyOn(Set.prototype, 'delete').mockImplementation(function ( + this: Set, + value: unknown + ) { + const deleted = deleteEntry.call(this, value) + if (deleted && owners.has(value)) { + removed.push(value) + } + return deleted + }) + const physicalExit = flights.physicalExit.bind(flights) + vi.spyOn(flights, 'physicalExit').mockImplementation((...args) => { + const view = physicalExit(...args) + return { + ...view, + current: () => { + currentChecks++ + return view.current() + } + } + }) + const waiterCount = () => [...observers.values()].reduce((n, o) => n + o.waiterCount, 0) + const reactions = () => [...owners.values()].reduce((n, o) => n + o.reactions(), 0) + const forceClose = () => vi.advanceTimersByTimeAsync(SUPERVISED_GRACEFUL_EXIT_MS + 8000) + const paramsFor = (id: string) => { + const generated = fixture.params() + const record = fixture.host.deps.store.getRecord(id) + return record + ? attachParamsForRecord(record, { + clientOperationId: generated.envelope.clientOperationId, + expectedRuntimeFence: record.lease.runtimeFence + }) + : hostTestAttachParams(null, { + ...generated, + envelope: { ...generated.envelope, sessionId: id } + }) + } + const retire = async (id: string) => { + expect(await fixture.host.attach(fixture.caller, paramsFor(id))).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(fixture.host.readStatusSummary(id)?.hostExecutionPhase).toBe('ready') + ) + const index = fixture.children.length - 1 + fixture.permitKill.add(index) + const closing = fixture.host.close(id, 'evict') + await forceClose() + await closing + expect(fixture.connections[index]!.exitVerdict).toMatchObject({ + root: 'live', + killDelivered: true + }) + return index + } + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const identity = Promise.withResolvers() + let switching: Awaited> | undefined + try { + const target: number[] = [] + for (let n = 0; n < count; n++) { + target.push(await retire(SESSION)) + } + for (let n = 0; n < count; n++) { + await retire('unrelated-session') + } + expect(owners.size).toBe(2 * count) + switching = await nativeSwitchPhysicalFixture( + fixture.host, + SESSION, + fixture.directory, + 'claude' + ) + for (let n = 0; n < count; n++) { + const expired = switching.run(1000).catch((error: unknown) => error) + await vi.waitFor(() => expect(waiterCount()).toBe(1)) + await vi.advanceTimersByTimeAsync(1001) + expect(await expired).toMatchObject({ + refusal: { code: 'agent_session_operation_expired' } + }) + expect(waiterCount()).toBe(0) + } + expect(visited).toHaveLength(count) + expect(reactions()).toBe(2 * count + 1) + expect(switching.spawn).not.toHaveBeenCalled() + const surviving = switching.run().catch((error: unknown) => error) + await vi.waitFor(() => expect(waiterCount()).toBe(1)) + const beforeCurrent = { visits: visited.length, reactions: reactions() } + const view = flights.physicalExit(SESSION, true) + for (let n = 0; n < count; n++) { + expect(view.current()).toBe(false) + } + expect({ visits: visited.length, reactions: reactions() }).toEqual(beforeCurrent) + + const later = fixture.children.length + fixture.heldIdentity.set(9100000 + later * 100, identity.promise) + fixture.permitKill.add(later) + const ensuring = fixture.host + .attach(fixture.caller, paramsFor(SESSION)) + .catch((error: unknown) => error) + await vi.waitFor(() => expect(fixture.connections).toHaveLength(later + 1)) + fixture.children[later]!.stdin.emit('error', new Error('ordinary later ensure failed')) + await forceClose() + identity.resolve() + await forceClose() + expect(await ensuring).toMatchObject({ ok: false }) + expect(owners.size).toBe(2 * count + 1) + const kills = fixture.signal.mock.calls.length + const afterRetries = visited.length - 1 + for (const index of target) { + fixture.observedExit(index) + await vi.advanceTimersByTimeAsync(0) + await fixture.drain() + } + await vi.waitFor(() => expect(visited).toHaveLength(2 * count + 1)) + expect(removed).toHaveLength(count) + expect(switching.spawn).not.toHaveBeenCalled() + expect(waiterCount()).toBe(1) + const completed = new Set(removed) + expect(visited.slice(afterRetries)).toHaveLength(count + 1) + expect(new Set(visited.slice(afterRetries)).size).toBe(count + 1) + fixture.observedExit(later) + await vi.advanceTimersByTimeAsync(0) + expect(await surviving).toMatchObject({ status: 'completed' }) + expect(switching.spawn).toHaveBeenCalledOnce() + expect(fixture.signal.mock.calls).toHaveLength(kills) + expect(waiterCount()).toBe(0) + expect(removed).toHaveLength(count + 1) + expect(reactions()).toBe(3 * count + 2) + expect(currentChecks).toBeGreaterThanOrEqual(2 * count + 1) + expect(currentChecks).toBeLessThan(3 * count + 20) + expect([...observers.keys()].every((p) => owners.get(p)?.sessionId === SESSION)).toBe(true) + expect(removed.slice(count).some((p) => completed.has(p))).toBe(false) + expect(view.current()).toBe(true) + expect(fixture.prompts).toEqual([]) + console.info( + 'native-physical-counts', + JSON.stringify({ + acquisitions: owners.size, + retries: count, + traversed: visited.length, + reactions: reactions(), + currentChecks, + liveWaiters: waiterCount(), + removed: removed.length, + destinations: switching.spawn.mock.calls.length + }) + ) + } finally { + identity.resolve() + switching?.dispose() + await fixture.dispose() + } + } +) diff --git a/src/main/runtime/structured-agent-session-pi-kill-retry.test.ts b/src/main/runtime/structured-agent-session-pi-kill-retry.test.ts index c16ea9e086f8..81333be6e54a 100644 --- a/src/main/runtime/structured-agent-session-pi-kill-retry.test.ts +++ b/src/main/runtime/structured-agent-session-pi-kill-retry.test.ts @@ -11,6 +11,7 @@ import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { agentSessionSwitchDeadline } from '../../shared/agent-session-switch-execution' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { ensureStructuredAgentSessionHost, @@ -191,6 +192,21 @@ it('settles a production failed Pi start after background kill, then delivers on host['deps'].store.listOperationRows().some((row) => row.outcome.status === 'pending') ).toBe(false) expect(signal.mock.calls.filter(([pid]) => pid === -9000201)).toHaveLength(1) + const deadline = agentSessionSwitchDeadline() + let settled = false + const switching = host.stopForSwitch(SESSION, caller, deadline).then( + () => { + settled = true + return true + }, + () => { + settled = true + return false + } + ) + await vi.advanceTimersByTimeAsync(0) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(settled).toBe(false) expect(await send('A legitimate new user message.')).toMatchObject({ ok: true }) await vi.waitFor(() => expect(prompts).toEqual(['A legitimate new user message.'])) await vi.waitFor(async () => { @@ -200,4 +216,8 @@ it('settles a production failed Pi start after background kill, then delivers on }) expect(connections).toHaveLength(3) expect(observeStructuredSession(SESSION)).toEqual({ status: 'live' }) + children[1]!.emit('exit', 0, null) + expect(await switching).toBe(false) + deadline.dispose() + expect(observeStructuredSession(SESSION)).toEqual({ status: 'live' }) }) diff --git a/src/main/runtime/structured-agent-session-runtime-contract.ts b/src/main/runtime/structured-agent-session-runtime-contract.ts index faf2e5e86c00..3c0bab79c2ec 100644 --- a/src/main/runtime/structured-agent-session-runtime-contract.ts +++ b/src/main/runtime/structured-agent-session-runtime-contract.ts @@ -67,6 +67,7 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO prepareCodexCatalogProbeHome?: (homePath: string) => void /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] + captureSessionSwitchStop?: StructuredAgentSessionHostDeps['captureSessionSwitchStop'] assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter'] /** Host-owned phone delivery and reconciliation from the current journal projection. */ attentionDelivery?: StructuredAttentionMobileDelivery diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index 9dfbfb30fad0..5aa36739b81d 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -1,3 +1,5 @@ +// A windowless host's saved chat tab against a real chat host: the index hiding a chat reaches the +// runtime and takes the tab out of the layout, and a chat whose record is gone loses its tab. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index d27c53ab81d9..7f4a467a4bbc 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -251,10 +251,9 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), ...scriptedCodexTransport(codex.openConnection), onRequestFailure: (failure) => runtime.onStructuredRequestFailureForErrorMail(failure), - onSessionStatusChanged: (summary) => { - runtime.onStructuredSessionStatusForMail(summary) - }, - onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), + onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId), + captureSessionSwitchStop: (sessionId) => runtime.captureSessionSwitchStop(sessionId) }) dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts index 88e40db7e8d6..36728b04c26a 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -232,3 +232,5 @@ describe("a chat assignee's liveness, stop and close", () => { expect(JSON.stringify(read.transcript)).toContain('working on it') }) }) + +// Stop ends the provider turn; the same chat/caller still owns its direct assignment. diff --git a/src/main/runtime/structured-claude-piped-test-support.ts b/src/main/runtime/structured-claude-piped-test-support.ts index e30508eb2ecc..83a5b0a6aeb6 100644 --- a/src/main/runtime/structured-claude-piped-test-support.ts +++ b/src/main/runtime/structured-claude-piped-test-support.ts @@ -191,6 +191,7 @@ export async function claudePipedFixture() { host, children, connections, + prompts, delivered, heldIdentity, heldSpawn, diff --git a/src/main/runtime/structured-native-switch-physical.test-fixture.ts b/src/main/runtime/structured-native-switch-physical.test-fixture.ts new file mode 100644 index 000000000000..0b550d34ce6a --- /dev/null +++ b/src/main/runtime/structured-native-switch-physical.test-fixture.ts @@ -0,0 +1,130 @@ +import { expect, vi } from 'vitest' +import { join } from 'node:path' +import * as cliTranscript from '../ai-vault/structured-chat-cli-fork' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { agentSessionSwitchDeadline } from '../../shared/agent-session-switch-execution' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { structuredAgentRuntimeMetadata } from './structured-agent-runtime-metadata' +import { setupMainOwnedWorkspace } from './main-owned-workspace-runtime-fixture' +import { executeStructuredSessionTerminalSwitch } from './rpc/methods/structured-agent-session-switch-terminal-execution' + +/** Real switch/ordinary layout writers, with only provider transcript I/O and terminal spawn external. */ +export async function nativeSwitchPhysicalFixture( + host: StructuredAgentSessionHost, + sessionId: string, + directory: string, + agent: 'claude' | 'codex' +) { + const record = host.deps.store.getRecord(sessionId)! + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)! + const { runtime, bindPty, handleOf } = setupMainOwnedWorkspace() + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: record.location.workspaceId, + path: directory, + connectionId: null, + repo: null, + folderWorkspace: null + }) + }) + await runtime.publishStructuredAgentSessionTab({ + workspaceId: record.location.workspaceId, + sessionId, + agent, + activate: true + }) + vi.spyOn(cliTranscript, 'resolveStructuredChatCliSource').mockResolvedValue({ + host, + record, + head, + agent, + cwd: directory, + providerSessionId: head.handle.nativeId, + metadata: structuredAgentRuntimeMetadata(agent)!, + requireCurrent: () => { + const current = host.deps.store.requireWritableRecord(sessionId) + expect(current.location).toEqual(record.location) + expect(agentSessionProviderHandleChainHead(current.providerHandleChain)?.linkId).toBe( + head.linkId + ) + } + }) + vi.spyOn(cliTranscript, 'prepareStructuredChatCliTranscript').mockResolvedValue({ + useRealCodexHome: false, + cliForkSource: { + agent, + sourceAgent: agent, + sessionId: head.handle.nativeId, + filePath: join(directory, 'provider-transcript.jsonl'), + cwd: directory, + codexHome: null, + executionHostPlatform: process.platform + } + }) + const spawn = vi + .spyOn(runtime, 'createTerminal') + .mockImplementation(async (_selector, opts = {}) => { + if (!opts.tabId || !opts.leafId) { + throw new Error('missing ordinary published pane') + } + opts.beforePtySpawn?.() + opts.onPtySpawnDispatched?.({ + tabId: opts.tabId, + leafId: opts.leafId, + launchAgent: agent, + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}) + }) + bindPty({ + worktreeId: record.location.workspaceId, + tabId: opts.tabId, + leafId: opts.leafId, + ptyId: 'physical-switch-destination' + }) + runtime.registerPty('physical-switch-destination', record.location.workspaceId, null, { + tabId: opts.tabId, + leafId: opts.leafId, + terminalHandle: opts.preAllocatedHandle + }) + return { + handle: handleOf('physical-switch-destination'), + ptyId: 'physical-switch-destination', + tabId: opts.tabId, + paneKey: `${opts.tabId}:${opts.leafId}`, + worktreeId: record.location.workspaceId, + title: null, + surface: 'background' + } + }) + let invocation = 0 + const deadlines: ReturnType[] = [] + return { + runtime, + spawn, + run: (budget = 120_000) => { + const deadline = agentSessionSwitchDeadline(() => performance.now(), budget) + deadlines.push(deadline) + const execution = { + invocationId: `exact-source-${++invocation}`, + sourceTabId: `agent-session:${sessionId}` + } + return host.runSwitch( + sessionId, + execution.invocationId, + deadline, + () => + executeStructuredSessionTerminalSwitch( + { sessionId, execution }, + host.deps.store.getRecord(sessionId)!, + { runtime }, + host, + sessionId, + deadline, + execution, + runtime.captureSessionSwitchSelection() + ), + { direction: 'terminal' } + ) + }, + dispose: () => deadlines.forEach((deadline) => deadline.dispose()) + } +} diff --git a/src/main/runtime/terminal-close-slow-agent-exit.test.ts b/src/main/runtime/terminal-close-slow-agent-exit.test.ts index 0158f3927daf..93d07cef6be0 100644 --- a/src/main/runtime/terminal-close-slow-agent-exit.test.ts +++ b/src/main/runtime/terminal-close-slow-agent-exit.test.ts @@ -110,9 +110,9 @@ describe('worker release of an agent that is slow to exit after its terminal clo const harness = await startLateExitHarness() try { const subprocess = harness.subprocess - subprocess.forceKill = () => { + subprocess.forceKill.mockImplementation(() => { setTimeout(() => subprocess._simulateExit(0), SLOW_EXIT_MS) - } + }) const startedAt = Date.now() const { receipt, fallbackKill } = await closeThroughDaemon(harness) expect(Date.now() - startedAt).toBeGreaterThanOrEqual(SLOW_EXIT_MS) @@ -148,7 +148,7 @@ describe('worker release of an agent that is slow to exit after its terminal clo vi.useFakeTimers({ toFake: ['Date'] }) try { // A wedged agent ignores SIGKILL, so only the close deadline can end the kill. - harness.subprocess.forceKill = () => {} + harness.subprocess.forceKill.mockImplementation(() => {}) let budgetMs = 0 const expiringStop: typeof stopAndWaitPtyFromRuntimeController = async (deps, id, opts) => { const deadlineMs = opts?.deadlineMs ?? 0 diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts index caf90a615763..aed25e68d2df 100644 --- a/src/main/runtime/terminal-intentional-stop-exit.test.ts +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -20,7 +20,9 @@ import { createPtyIpcSession } from '../ipc/pty/session' import type { IPtyProvider } from '../providers/pty-provider-contract' import { OrcaRuntimeService } from './orca-runtime' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { createCloseContinuityGraphFixture } from './__fixtures__/orca-runtime-terminal-close-continuity-graph-fixture' import { + CANARY_TAB_ID, INCARNATION_ID, LEAF_ID, PTY_ID, @@ -53,7 +55,9 @@ afterEach(() => { }) /** Use real store/runtime with renderer stub; lateProviderExit lets the kill reply overtake the provider exit and force a synthetic one. */ -function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { +function createHarness( + opts: { lateProviderExit?: boolean; folder?: boolean; includeCanary?: boolean } = {} +) { const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) directories.push(directory) const store = new Store({ @@ -73,7 +77,9 @@ function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = // Why: a folder workspace resolves without git, which the sleep transaction needs. ...(opts.folder ? { kind: 'folder' as const } : {}) }) - store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.setWorkspaceSession( + advanceTerminalTopologyRevision(makeSession(PTY_ID, opts.includeCanary), WORKTREE_ID) + ) store.flushOrThrow() const runtime = new OrcaRuntimeService(store) runtime.registerPty(PTY_ID, WORKTREE_ID, null, { @@ -138,6 +144,111 @@ function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = } describe('intentional stops keep the pane through the exit', () => { + it('keeps a captured typed source through provider exit until explicit retirement', async () => { + const harness = createHarness({ includeCanary: true }) + const { runtime, store } = harness + const closeTerminalTab = vi.fn(async () => {}) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn(), + closeTerminalTab + }) + runtime.attachWindow(1) + createCloseContinuityGraphFixture({ + runtime, + ptyId: PTY_ID, + publishMobileSurface: true, + includeCanary: true, + getSession: () => store.getWorkspaceSession(), + setSession: (session) => store.setWorkspaceSession(session), + markSiblingPtyIncluded: () => {} + }).syncFixtureGraph() + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID, incarnationId: INCARNATION_ID }) + runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'claude --resume fixture\r') + runtime.onPtyData(PTY_ID, 'Retained terminal reply\r\n', Date.now()) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Missing authenticated source') + } + const captured = runtime.captureTerminalSwitchClose( + source.handle, + source.incarnationId, + source.presentation + ) + const before = structuredClone(store.getWorkspaceSession()) + const entered = Promise.withResolvers() + const exit = Promise.withResolvers() + const stopAndWait = vi.fn(async () => { + entered.resolve() + await exit.promise + harness.emitProviderExit(INCARNATION_ID) + return runtime.getPtyLivenessVerdict(PTY_ID)?.status === 'exited' + }) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait, + getForegroundProcess: async () => 'claude' + }) + let stopped = false + const stopping = captured.stop().then(() => { + stopped = true + }) + await entered.promise + expect(stopped).toBe(false) + expect(harness.rendererExits()).toEqual([]) + expect(closeTerminalTab).not.toHaveBeenCalled() + exit.resolve() + await stopping + + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toMatchObject({ status: 'exited' }) + expect(await runtime.readTerminal(source.handle)).toMatchObject({ + status: 'exited', + tail: ['Retained terminal reply'] + }) + expect(harness.tabIds()).toEqual([TAB_ID, CANARY_TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual( + before.terminalLayoutsByTabId + ) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + hostId: 'local', + preserveRendererBinding: 'closing' + } + ]) + expect(closeTerminalTab).not.toHaveBeenCalled() + await expect(captured.retire()).resolves.toMatchObject({ ptyKilled: true }) + expect(harness.tabIds()).toEqual([CANARY_TAB_ID]) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[CANARY_TAB_ID]).toEqual( + before.terminalLayoutsByTabId[CANARY_TAB_ID] + ) + expect(closeTerminalTab).toHaveBeenCalledExactlyOnceWith(TAB_ID, { + localPtyTeardownOwnedExternally: true, + originalSurface: source.presentation.identity + }) + expect(stopAndWait).toHaveBeenCalledExactlyOnceWith( + PTY_ID, + expect.objectContaining({ + expectedIncarnationId: INCARNATION_ID, + deadlineMs: expect.any(Number) + }) + ) + expect(kill).not.toHaveBeenCalled() + }) + it('retires the pane when an ordinary close ends the process', async () => { const harness = createHarness() diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts index b69601065733..7993da33fdcb 100644 --- a/src/main/runtime/terminal-intentional-stops.ts +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -1,7 +1,13 @@ import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' -/** Preserve pane bindings across intentional exits: reversible keeps a wake hint; replaced awaits immediate or reconnect-time replacement. */ -export type TerminalIntentionalStopKind = 'reversible' | 'replaced' +/** + * Why main stopped a PTY on purpose. These kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `closing`: physical teardown is externally owned; explicit presentation removal follows. + * - `replaced`: a restart stops the old process, or a reattach finds it gone; a new one takes over, + * at once or (after a daemon restart) when the pane next connects. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' | 'closing' type IntentionalStopOwners = { inFlight: number; stopped: boolean } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 2864144ce7ef..2da219d656c9 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -1,3 +1,8 @@ +import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' +import { + matchesTerminalSurfaceCapture, + type TerminalSurfaceCloseCapture +} from '../../shared/terminal-tab-close' import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { @@ -160,6 +165,8 @@ export type TerminalSurfaceCloseOptions = { /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with * D1, once main owns the terminal layout. */ closedByLayoutOwner?: boolean + originalPresentation?: TerminalSurfaceCloseCapture + onClosed?: (closedTab: boolean) => void } /** The desktop renderer's close intent as its IPC delivers it; main alone passes 'pty-exit'. */ @@ -178,7 +185,7 @@ export type TerminalSurfaceCloseCommit = { requestedSession: WorkspaceSessionState | null | undefined /** The tab's owner identity still matches the one the close was asked against. */ ownerMatches: () => boolean - /** The partitions holding the tab, routed partition first; empty when unroutable. */ + /** The tab's partitions, routed first; original-aware calls return captured and current holders. */ hostIds: () => readonly ExecutionHostId[] /** Fence every holder against new/rebound copies, not just ownerMatches' first holder; capture snapshots because binding writes mutate sessions in place. */ requestedHolders?: ReadonlyMap @@ -217,7 +224,12 @@ export function terminalSurfaceCloseMutation( ] : undefined return () => { - if (!commit.ownerMatches()) { + const original = commit.options.originalPresentation + const hostIds = commit.hostIds() + if ( + !commit.ownerMatches() || + (original && !matchesTerminalSurfaceCapture(original, hostIds, commit.getSession)) + ) { return { value: new Error('terminal_pane_owner_changed'), persist: false } } const results: { @@ -225,16 +237,31 @@ export function terminalSurfaceCloseMutation( session: WorkspaceSessionState result: TerminalSurfaceCloseResult }[] = [] - for (const hostId of commit.hostIds()) { + for (const hostId of hostIds) { const session = commit.getSession(hostId) if (!session) { return { value: new Error('workspace_session_unavailable'), persist: false } } - const result = closeTerminalSurfaceInWorkspaceSession(session, commit.worktreeId, target, { - force: commit.options.force, - paneIncarnationId, - reason: commit.options.reason ?? 'user' - }) + const resolvedTarget = + commit.options.originalPresentation && + target.kind === 'pane' && + (resolvePaneClose( + collectTerminalLayoutLeafIds(session.terminalLayoutsByTabId[target.tabId]?.root), + target.leafId + ) === 'last-pane' || + !session.tabsByWorktree[commit.worktreeId]?.some((row) => row.id === target.tabId)) + ? { kind: 'tab' as const, tabId: target.tabId } + : target + const result = closeTerminalSurfaceInWorkspaceSession( + session, + commit.worktreeId, + resolvedTarget, + { + force: commit.options.force, + paneIncarnationId: original?.identity.incarnationId ?? paneIncarnationId, + reason: commit.options.reason ?? 'user' + } + ) if (result.pinned) { return { value: new Error('terminal_tab_pinned'), persist: false } } @@ -243,6 +270,7 @@ export function terminalSurfaceCloseMutation( const { requestedHolders } = commit const changedCopy = requestedHolders !== undefined && + !commit.options.originalPresentation && results .slice(1) .some( @@ -257,6 +285,7 @@ export function terminalSurfaceCloseMutation( return { value: new Error('terminal_pane_owner_changed'), persist: false } } const closed = results.filter(({ result }) => result.closed) + commit.options.onClosed?.(results[0]?.result.resolution === 'tab') if (closed.length === 0) { const routed = results[0] if (!routed) { @@ -266,7 +295,7 @@ export function terminalSurfaceCloseMutation( // Stamp missing tabs in the routed partition to reject late spawns; repeated close echoes need no second write. if ( commit.options.allowMissing && - target.kind === 'tab' && + (target.kind === 'tab' || (original && routed.result.resolution === 'tab')) && !hasClosedTerminalTabRecord(routed.session.closedTerminalTabTombstonesByTabId, target.tabId) ) { commit.setSession(routed.result.session, routed.hostId) @@ -281,6 +310,9 @@ export function terminalSurfaceCloseMutation( for (const { hostId, result } of closed) { commit.setSession(result.session, hostId) } + if (original) { + markAgentLaunchesClosedByUser(commit.worktreeId, target) + } commit.onClosed([...new Set(closed.flatMap(({ result }) => result.ptyIdsToKill))]) // Bookkeeping cannot undo user close or skip kill; failed persistence leaves the in-memory removal dirty for the next write. return { value: undefined } diff --git a/src/main/runtime/terminal/focus/focus-terminal.ts b/src/main/runtime/terminal/focus/focus-terminal.ts index 189db51faffd..20987f3ecb0a 100644 --- a/src/main/runtime/terminal/focus/focus-terminal.ts +++ b/src/main/runtime/terminal/focus/focus-terminal.ts @@ -19,6 +19,9 @@ export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitFo options: { navigateHost?: boolean } = {} ): Promise { const navigateHost = options.navigateHost !== false + if (navigateHost) { + this.clientSessionTabSelections.noteHostNavigation() + } const livePtyIdentity = (): RuntimeTerminalFocus => { const live = this.getLivePtyForHandle(handle) if (!live?.pty.connected) { diff --git a/src/main/runtime/terminal/identity/terminal-handle-bindings.ts b/src/main/runtime/terminal/identity/terminal-handle-bindings.ts index 49a8d540f7c8..9c33216165b4 100644 --- a/src/main/runtime/terminal/identity/terminal-handle-bindings.ts +++ b/src/main/runtime/terminal/identity/terminal-handle-bindings.ts @@ -61,7 +61,17 @@ export abstract class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntim for (const [ptyId, retained] of this.handleByPtyIncarnation) { const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) - // Learning a previously unknown incarnation fences the handle; only known-to-different means replacement. + const retainedLeaf = this.leaves.get(retained.leafKey) + if ( + (!pty || (!pty.connected && pty.incarnationId === retained.incarnationId)) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' && + retainedLeaf && + (!retainedLeaf.ptyId || retainedLeaf.ptyId === ptyId) + ) { + continue + } + // Why: a handle issued before the host reported the incarnation is un-fenced, so + // learning it is not a replacement; only a known-to-different incarnation is. const incarnationReplaced = retained.incarnationId !== null && pty !== undefined && @@ -166,12 +176,20 @@ export abstract class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntim } } - protected invalidateLeafHandle(leafKey: string): void { + protected invalidateLeafHandle(leafKey: string, bindingCleared = false): void { const handle = this.handleByLeafKey.get(leafKey) if (!handle) { return } const record = this.handles.get(handle) + // A cleared binding after certified exit still addresses that readable ended process. + if ( + bindingCleared && + record?.ptyId && + this.getPtyLivenessVerdict(record.ptyId)?.status === 'exited' + ) { + return + } if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) { this.handleByPtyIncarnation.delete(record.ptyId) } diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 4dac063946a8..d9adc4c7f234 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -1,4 +1,7 @@ -// Main-owned starts retain a failed pane with its reason, even when failure preceded layout persistence. +import { rekeyMovedPaneOwners } from '../../moved-pane-owner-rekey' +import { applyWindowLayoutCommand } from '../../runtime-layout-apply' +// Layout-owner build: main's starts publish their status on the pane, and a start main could not +// make leaves its pane showing why, whether it failed before the pane was written or after. import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { isHibernationDoneRecord } from '../../../../shared/hibernation-done-record' import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' @@ -42,8 +45,32 @@ export async function startKeepingFailedPane( export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCreateTerminal { protected abstract markPaneLiveTitlesDirty(): void + protected abstract inResumeRecordOrder(write: () => Promise): Promise abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + protected async applyOwnedWindowLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise { + // A workspace's first surface comes after the live terminals main is placing. + const placing = this.livePlacementSettledFor(command) + const apply = (): Promise => + applyWindowLayoutCommand( + this.requireStore(), + (worktreeId, tabId) => + tabId !== null + ? this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) + : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), + command, + (move) => rekeyMovedPaneOwners(this, move), + options ? { ...this.layoutCommandRun(command), ...options } : this.layoutCommandRun(command) + ) + // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. + return await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) + ? this.inResumeRecordOrder(apply) + : (placing?.then(apply) ?? apply())) + } + /** Switch point: main's starts into panes it already holds, published until each binds. */ protected readonly paneStartStatuses = ORCA_LAYOUT_OWNER_IS_MAIN ? new PaneStartStatuses(() => this.markPaneLiveTitlesDirty()) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create-anchor.ts b/src/main/runtime/terminal/launch/mobile-terminal-create-anchor.ts index a5dcf9aef6ea..e5acb344c974 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create-anchor.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create-anchor.ts @@ -1,3 +1,5 @@ +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { captureSessionTabPlacement } from '../../../../shared/session-tab-placement' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { RuntimeMobileSessionSnapshotTab } from '../../../../shared/runtime-mobile-session-tab-contracts' @@ -30,3 +32,22 @@ export function mainPlacedAfter( ? { afterDesktopTabId: heldTabId() ?? afterDesktopTabId } : {} } + +export function captureMobileTerminalCreatePlacement( + beforeCreate: RuntimeMobileSessionTabsSnapshot | undefined, + afterTabId: string | undefined, + afterDesktopTabId: string | undefined +) { + const anchor = beforeCreate?.tabs.find((tab) => tab.id === afterTabId) + const anchorParentId = + afterDesktopTabId ?? (anchor?.type === 'terminal' ? anchor.parentTabId : anchor?.id) + const sourceGroup = beforeCreate?.tabGroups?.find((group) => + group.tabOrder.includes(anchorParentId ?? '') + ) + const capturedOrder = captureSessionTabPlacement(sourceGroup?.tabOrder ?? [], anchorParentId) + const capturedTabs = captureSessionTabPlacement( + beforeCreate?.tabs.map((tab) => tab.id) ?? [], + afterTabId + ) + return { sourceGroup, capturedOrder, capturedTabs } +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create-options.ts b/src/main/runtime/terminal/launch/mobile-terminal-create-options.ts new file mode 100644 index 000000000000..4f6f48799804 --- /dev/null +++ b/src/main/runtime/terminal/launch/mobile-terminal-create-options.ts @@ -0,0 +1,43 @@ +import type { AgentSessionSwitchAuthority } from '../../../../shared/agent-session-switch-execution' +import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' +import type { AgentLaunchTabViewerRule } from '../../../../shared/agent-launch-tab-publication' +import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' + +export type MobileTerminalCreateOptions = { + terminalImageViewerSupported?: boolean + afterTabId?: string + sourceChatFork?: { sessionId: string } + targetGroupId?: string + command?: string + sessionOptions?: Record + commandOverride?: string + launchRuntime?: AgentLaunchRuntime + shellOverride?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + agentPrompt?: string + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + /** A switch's start: its ordinary spawn admission checks the conversation. */ + resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'switch' + viewMode?: 'terminal' | 'chat' + activate?: boolean + clientNavigationId?: string + clientMutationId?: string + supportsSplitGroupPlacement?: boolean + signal?: AbortSignal + switchExecution?: AgentSessionSwitchAuthority & { + afterTabId?: string + viewer: AgentLaunchTabViewerRule + viewerSelection?: string + } +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create-owner.ts b/src/main/runtime/terminal/launch/mobile-terminal-create-owner.ts new file mode 100644 index 000000000000..dd33511d1381 --- /dev/null +++ b/src/main/runtime/terminal/launch/mobile-terminal-create-owner.ts @@ -0,0 +1,62 @@ +import type { + AgentLaunchTabPublishRequest, + AgentLaunchTabPublished +} from '../../../../shared/agent-launch-tab-publication' +import type { + AgentLaunchPaneAddress, + AgentLaunchPaneVerdict +} from '../../../../shared/agent-launch-pane-verdict' +import type { MobileTerminalCommandOptions, MobileTerminalCommand } from './mobile-terminal-startup' +import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' +import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' +import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' +import { OrcaRuntimeWithMobileChatForkCreate } from './mobile-terminal-chat-fork-create' + +export abstract class OrcaRuntimeWithMobileTerminalCreateOwner extends OrcaRuntimeWithMobileChatForkCreate { + abstract publishAgentLaunchTab( + request: Omit + ): Promise | null + + abstract reportAgentLaunchPaneVerdict( + pane: AgentLaunchPaneAddress, + verdict: AgentLaunchPaneVerdict + ): void + + protected abstract resolveMobileSessionTerminalCommand( + workspace: TerminalWorkspaceLaunchScope, + opts: MobileTerminalCommandOptions + ): Promise + + protected abstract ensurePtyBackedMobileSurfaceForRendererTab( + worktreeId: string, + tabId: string + ): RuntimeMobileSessionCreateTerminalResult | null + + protected abstract waitForMobileTerminalSurface( + worktreeId: string, + parentTabId: string, + options?: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } + ): Promise + + protected abstract isReadyMobileTerminalSurface( + surface: RuntimeMobileSessionCreateTerminalResult | null + ): boolean + + protected abstract deliverPendingStartupCommandToBareRendererPty( + worktreeId: string, + tabId: string + ): void + + protected abstract findMobileTerminalSurface( + worktreeId: string, + parentTabId: string, + options?: { requireReady?: boolean } + ): RuntimeMobileSessionCreateTerminalResult | null + + protected abstract findLiveRegisteredPtyForRendererTab( + worktreeId: string, + tabId: string + ): RuntimePtyWorktreeRecord | null + + protected abstract hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create-reconciliation.ts b/src/main/runtime/terminal/launch/mobile-terminal-create-reconciliation.ts new file mode 100644 index 000000000000..320d266d4be3 --- /dev/null +++ b/src/main/runtime/terminal/launch/mobile-terminal-create-reconciliation.ts @@ -0,0 +1,163 @@ +import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup' +import type { + OwnedMobileTerminalCreateOptions, + OwnedMobileTerminalDispatchRecipe +} from './mobile-terminal-create' +import type { RuntimeTerminalCreate } from '../../../../shared/runtime-types' +import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' +import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + ptyHoldsRecordedSurface, + type PtySurfaceTopology +} from '../../pty-recorded-surface-topology' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { persistFreshPaneBinding } from '../../../ipc/pty/spawn-commit-binding' + +export abstract class OrcaRuntimeWithReconcileOwnedMobileTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand { + protected abstract ptySurfaceTopology(): PtySurfaceTopology + + protected validateOwnedMobileDispatch( + pty: RuntimePtyWorktreeRecord, + terminal: RuntimeTerminalCreate, + opts: OwnedMobileTerminalCreateOptions, + dispatched: OwnedMobileTerminalDispatchRecipe | undefined, + connectionId: string | null + ): OwnedMobileTerminalDispatchRecipe | undefined { + const recipe = + dispatched?.connectionId === connectionId && + (!pty.launchAgent || pty.launchAgent === dispatched.launchAgent) + ? dispatched + : undefined + const originalAgent = pty.launchAgent ?? recipe?.launchAgent + if (originalAgent && opts.launchAgent && originalAgent !== opts.launchAgent) { + throw new Error('terminal_create_identity_conflict') + } + if ( + !terminal.agentResumeRefused && + !terminal.agentResumeUnavailable && + opts.launchConfig && + !pty.launchConfig && + !recipe?.launchConfig + ) { + throw new Error('runtime_unavailable') + } + if (recipe && !terminal.agentResumeRefused && !terminal.agentResumeUnavailable) { + pty.launchAgent ??= recipe.launchAgent ?? null + if (!pty.launchConfig && recipe.launchConfig) { + pty.launchConfig = copySleepingAgentLaunchConfig(recipe.launchConfig) + } + } + return recipe + } + + private currentOwnedMobileSurface(pty: RuntimePtyWorktreeRecord) { + const recorded = this.paneOfPty(pty) + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + const leaf = this.getLeavesForPty(pty.ptyId).find((row) => row.worktreeId === pty.worktreeId) + if (leaf) { + return { tabId: leaf.tabId, leafId: leaf.leafId } + } + } + const pane = parsePaneKey(recorded.paneKey ?? '') + if (ORCA_LAYOUT_OWNER_IS_MAIN && pane) { + return pane + } + const session = this.getWorkspaceSessionForWorktree(pty.worktreeId) + for (const tab of session?.tabsByWorktree[pty.worktreeId] ?? []) { + for (const [leafId, id] of Object.entries( + session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + )) { + if ( + id === pty.ptyId && + (!pty.incarnationId || + session?.terminalPtyIncarnationsByPaneKey?.[`${tab.id}:${leafId}`] === + pty.incarnationId) + ) { + return { tabId: tab.id, leafId } + } + } + } + if (pane && ptyHoldsRecordedSurface(pty.ptyId, recorded, this.ptySurfaceTopology())) { + return pane + } + if (recorded.tabId || recorded.paneKey) { + throw new Error('terminal_handle_stale') + } + return null + } + + protected async reconcileOwnedMobileTerminalSurface( + worktreeId: string, + connectionId: string | null, + terminal: RuntimeTerminalCreate, + pty: RuntimePtyWorktreeRecord, + recipe: OwnedMobileTerminalDispatchRecipe | undefined + ) { + const incarnationId = pty.incarnationId + const resolve = () => { + const live = this.getLivePtyForHandle(terminal.handle)?.pty + if ( + live !== pty || + !pty.connected || + pty.incarnationId !== incarnationId || + pty.worktreeId !== worktreeId || + terminal.worktreeId !== worktreeId || + pty.connectionId !== connectionId || + (terminal.ptyId && terminal.ptyId !== pty.ptyId) || + (terminal.incarnationId && terminal.incarnationId !== incarnationId) + ) { + throw new Error('terminal_handle_stale') + } + const current = this.currentOwnedMobileSurface(pty) + if (current) { + return current + } + const returned = terminal.tabId && terminal.paneKey ? parsePaneKey(terminal.paneKey) : null + if (terminal.tabId && terminal.paneKey && (!returned || returned.tabId !== terminal.tabId)) { + throw new Error('terminal_handle_stale') + } + const candidate = returned ?? recipe + if (!candidate) { + throw new Error('terminal_handle_stale') + } + const leaf = this.leaves.get(this.getLeafKey(candidate.tabId, candidate.leafId)) + if (leaf && (leaf.worktreeId !== worktreeId || (leaf.ptyId && leaf.ptyId !== pty.ptyId))) { + throw new Error('terminal_handle_stale') + } + return { tabId: candidate.tabId, leafId: candidate.leafId } + } + const pendingBinding = persistFreshPaneBinding( + this.store, + () => ({ + ...resolve(), + worktreeId, + ptyId: pty.ptyId, + ...(incarnationId ? { incarnationId } : {}), + mayReviveRetiredSurface: false + }), + connectionId + ) + if (pendingBinding && !(await pendingBinding)) { + throw new Error('terminal_handle_stale') + } + const surface = resolve() + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + this.paneOfPty(pty).paneKey !== `${surface.tabId}:${surface.leafId}` + ) { + throw new Error('terminal_handle_stale') + } + if ( + !ORCA_LAYOUT_OWNER_IS_MAIN && + this.paneOfPty(pty).paneKey !== `${surface.tabId}:${surface.leafId}` + ) { + this.registerPty(pty.ptyId, worktreeId, connectionId, { + ...surface, + terminalHandle: terminal.handle, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return surface + } +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index 985b898c9d8a..9982d83fb500 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -1,3 +1,5 @@ +import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' +import type { MobileTerminalCreateOptions } from './mobile-terminal-create-options' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts' import { OrcaRuntimeWithFailedStartPanes } from './failed-start-pane' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' @@ -17,31 +19,57 @@ import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime import { navigationTargetsHost } from '../../../../shared/runtime-navigation' import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from '../../orca-runtime-core' import { seedResumeLaunchIdentity } from '../../../ipc/pty/pane/resume-launch-identity' +import type { AgentSessionSwitchAuthority } from '../../../../shared/agent-session-switch-execution' -/** A paired client's terminal create request. */ -export type MobileTerminalCreateOptions = { - terminalImageViewerSupported?: boolean - afterTabId?: string - sourceChatFork?: { sessionId: string } - targetGroupId?: string - command?: string - shellOverride?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - agent?: TuiAgent - agentPrompt?: string +export type OwnedMobileTerminalDispatchRecipe = { + tabId: string + leafId: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - activate?: boolean - clientNavigationId?: string - clientMutationId?: string - supportsSplitGroupPlacement?: boolean - signal?: AbortSignal + connectionId: string | null + recordedAt: number } +const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000 + +export function pruneOwnedMobileTerminalDispatchRecipes( + recipes: Map +): void { + const now = Date.now() + for (const [handle, recipe] of recipes) { + if (now - recipe.recordedAt >= OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS) { + recipes.delete(handle) + } + } +} + +export function recordOwnedMobileTerminalDispatchRecipe( + recipes: Map, + handle: string, + launch: Pick< + OwnedMobileTerminalDispatchRecipe, + 'tabId' | 'leafId' | 'launchConfig' | 'launchAgent' + >, + workspace: { connectionId?: string | null } +): void { + if (!recipes.has(handle) && recipes.size >= 4096) { + throw new Error('runtime_unavailable') + } + // Retain original dispatch evidence across an ambiguous response; retry payloads cannot replace it. + recipes.set(handle, { + tabId: launch.tabId, + leafId: launch.leafId, + connectionId: workspace.connectionId ?? null, + recordedAt: Date.now(), + ...(launch.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(launch.launchConfig) } + : {}), + ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}) + }) +} + +export type { MobileTerminalCreateOptions } from './mobile-terminal-create-options' + export type OwnedMobileTerminalCreateOptions = { /** A paired client's own create or tab open; without it a stopped WSL distro holds the start. */ wslUserAct?: true @@ -61,13 +89,17 @@ export type OwnedMobileTerminalCreateOptions = { supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' spawnIntent?: SpawnIntent launchRuntime?: AgentLaunchRuntime + switchExecution?: AgentSessionSwitchAuthority & + Pick, 'viewerSelection'> signal?: AbortSignal refuseSleptWorktree?: boolean resumesSleepingRecord?: true /** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */ + beforePtySpawn?: () => void + keepPaneOnStartFailure?: true onPtySpawnDispatched?: () => void /** The PTY this create produced, reported before publication can fail. */ onPtyCreated?: (ptyId: string, result: RuntimeTerminalCreate) => void @@ -97,6 +129,9 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun sourceChatFork?: { sessionId: string } targetGroupId?: string command?: string + sessionOptions?: Record + commandOverride?: string + launchRuntime?: AgentLaunchRuntime shellOverride?: string cwd?: string env?: Record @@ -106,6 +141,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'switch' launchAgent?: TuiAgent terminalImageViewerSupported?: boolean viewMode?: 'terminal' | 'chat' @@ -117,11 +153,15 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun // Older mobile clients optimistically append; preserve that placement until they advertise // split-group ordering support. supportsSplitGroupPlacement?: boolean + switchExecution?: MobileTerminalCreateOptions['switchExecution'] signal?: AbortSignal } = {} ): Promise { const navigation = opts.navigation ?? 'all' const select = opts.select ?? opts.activate !== false + if (select && navigationTargetsHost(navigation) && !opts.switchExecution) { + this.clientSessionTabSelections.noteHostNavigation() + } const runOpts = { ...opts, activate: select && navigationTargetsHost(navigation) @@ -173,7 +213,12 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun created: RuntimeMobileSessionCreateTerminalResult, opts: { resumeProviderSession?: AgentProviderSessionMetadata; launchAgent?: TuiAgent } ): void { - if (!opts.resumeProviderSession || !created.tab.terminal) { + if ( + created.agentResumeRefused || + created.agentResumeUnavailable || + !opts.resumeProviderSession || + !created.tab.terminal + ) { return } const pty = this.getLivePtyForHandle(created.tab.terminal)?.pty diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index e1429d360c05..668d02dee184 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -1,50 +1,38 @@ -import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import { + isSessionTabSelectionRequest, + placeCreatedSessionTab +} from '../../../../shared/session-tab-placement' +import { captureMobileTerminalCreatePlacement } from './mobile-terminal-create-anchor' +import { OrcaRuntimeWithReconcileOwnedMobileTerminal } from './mobile-terminal-create-reconciliation' import type { RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' -import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-create' +import { + pruneOwnedMobileTerminalDispatchRecipes, + recordOwnedMobileTerminalDispatchRecipe, + type OwnedMobileTerminalCreateOptions, + type OwnedMobileTerminalDispatchRecipe +} from './mobile-terminal-create' import { randomUUID } from 'node:crypto' -import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../../remote-runtime-terminal-create-identity' -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { placeCreatedSessionTab } from '../../../../shared/session-tab-placement' -import { insertTabIdIntoOrder } from '../../../../shared/workspace-layout/tab-order' import type { OrcaRuntimeWithBuildPtyTerminalSummary as PtyTerminalSummaryMethods } from '../query/pty-terminal-summary' import { buildHeadlessMobileSessionTabGroups, + placeHeadlessSessionTabAtCapture, buildMaterializedHeadlessParentLayout, getHeadlessMobileSessionGroupId } from '../../mobile-session-layout-projection' -const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000 +export type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-create' -export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand { +export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithReconcileOwnedMobileTerminal { protected abstract getPtyManualTitle( ...args: Parameters ): ReturnType - private readonly ownedMobileDispatchRecipes = new Map< - string, - { - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - connectionId: string | null - recordedAt: number - } - >() - - private pruneOwnedMobileDispatchRecipes(): void { - const now = Date.now() - for (const [handle, recipe] of this.ownedMobileDispatchRecipes) { - if (now - recipe.recordedAt >= OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS) { - this.ownedMobileDispatchRecipes.delete(handle) - } - } - } + private readonly ownedMobileDispatchRecipes = new Map() protected async createRuntimeOwnedMobileSessionTerminal( worktreeId: string, @@ -52,7 +40,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext afterTabId?: string, opts: OwnedMobileTerminalCreateOptions = {} ): Promise { - this.pruneOwnedMobileDispatchRecipes() + pruneOwnedMobileTerminalDispatchRecipes(this.ownedMobileDispatchRecipes) const workspace = await this.resolveTerminalWorkspaceLaunchScope( `id:${worktreeId}`, undefined, @@ -75,25 +63,23 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext const create = (preAllocatedHandle?: string) => this.createTerminal(`id:${worktreeId}`, { focus: false, - ...(opts.presentation ? { presentation: opts.presentation } : {}), + ...(opts.switchExecution + ? { presentation: 'background' as const, agentLaunchViewer: () => 'none' as const } + : opts.presentation + ? { presentation: opts.presentation } + : {}), ...(opts.afterDesktopTabId ? { afterTabId: opts.afterDesktopTabId } : {}), + beforePtySpawn: opts.beforePtySpawn, + switchExecution: opts.switchExecution, + ...(opts.keepPaneOnStartFailure ? { keepPaneOnStartFailure: true as const } : {}), onPtySpawnDispatched: (launch) => { if (mutationHandle && launch) { - if ( - !this.ownedMobileDispatchRecipes.has(mutationHandle) && - this.ownedMobileDispatchRecipes.size >= 4096 - ) { - throw new Error('runtime_unavailable') - } - // Retain original dispatch evidence across an ambiguous response; retry payloads cannot replace it. - this.ownedMobileDispatchRecipes.set(mutationHandle, { - connectionId: workspace.connectionId ?? null, - recordedAt: Date.now(), - ...(launch.launchConfig - ? { launchConfig: copySleepingAgentLaunchConfig(launch.launchConfig) } - : {}), - ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}) - }) + recordOwnedMobileTerminalDispatchRecipe( + this.ownedMobileDispatchRecipes, + mutationHandle, + launch, + workspace + ) } // Why after the recipe: its refusal above is still before dispatch. opts.onPtySpawnDispatched?.() @@ -133,6 +119,13 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}), ...(opts.resumesSleepingRecord ? { resumesSleepingRecord: true } : {}) }) + const beforeCreate = this.mobileSessionTabsByWorktree.get(worktreeId) + const { sourceGroup, capturedOrder, capturedTabs } = captureMobileTerminalCreatePlacement( + beforeCreate, + afterTabId, + opts.afterDesktopTabId + ) + let targetGroupId = opts.targetGroupId ?? sourceGroup?.id const terminal = opts.createMutation ? await this.dedupeTerminalCreate( opts.createMutation.clientIdentity, @@ -150,47 +143,29 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext if (!livePty) { throw new Error('terminal_handle_stale') } + const dispatched = + mutationHandle === terminal.handle + ? this.ownedMobileDispatchRecipes.get(terminal.handle) + : undefined + const recipe = opts.createMutation + ? this.validateOwnedMobileDispatch( + livePty.pty, + terminal, + opts, + dispatched, + workspace.connectionId ?? null + ) + : undefined + const { tabId: parentTabId, leafId } = await this.reconcileOwnedMobileTerminalSurface( + worktreeId, + workspace.connectionId ?? null, + terminal, + livePty.pty, + recipe + ) if (opts.createMutation) { - if ( - livePty.pty.launchAgent && - opts.launchAgent && - livePty.pty.launchAgent !== opts.launchAgent - ) { - throw new Error('terminal_create_identity_conflict') - } - const dispatchedRecipe = - mutationHandle === terminal.handle && this.ownedMobileDispatchRecipes.get(terminal.handle) - const recipe = - dispatchedRecipe && - dispatchedRecipe.connectionId === (workspace.connectionId ?? null) && - (!livePty.pty.launchAgent || livePty.pty.launchAgent === dispatchedRecipe.launchAgent) - ? dispatchedRecipe - : null - if (opts.launchConfig && !livePty.pty.launchConfig && !recipe?.launchConfig) { - // Missing original dispatch evidence cannot authorize a retry's captured recipe. - throw new Error('runtime_unavailable') - } - if (recipe) { - livePty.pty.launchAgent ??= recipe.launchAgent ?? null - if (!livePty.pty.launchConfig && recipe.launchConfig) { - livePty.pty.launchConfig = copySleepingAgentLaunchConfig(recipe.launchConfig) - } - } livePty.pty.runtimeSessionOwned = true - if (mutationHandle) { - this.ownedMobileDispatchRecipes.delete(mutationHandle) - } } - if (opts.createMutation && opts.identity && terminal.ptyId) { - this.registerPty(terminal.ptyId, worktreeId, workspace.connectionId ?? null, { - tabId: opts.identity.tabId, - leafId: opts.identity.leafId, - terminalHandle: terminal.handle - }) - } - const recorded = this.paneOfPty(livePty.pty) - const parentTabId = recorded.tabId ?? `pty:${livePty.pty.ptyId}` - const leafId = parsePaneKey(recorded.paneKey ?? '')?.leafId ?? randomUUID() const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( @@ -199,6 +174,9 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext candidate.parentTabId === parentTabId && candidate.leafId === leafId ) ?? null + if (existingSurface) { + targetGroupId = existing?.tabGroups?.find((group) => group.tabOrder.includes(parentTabId))?.id + } const parentLayout = buildMaterializedHeadlessParentLayout( leafId, livePty.pty.ptyId, @@ -209,58 +187,72 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext existingSurface?.customTitle !== undefined ? existingSurface.customTitle : this.getPtyManualTitle(livePty.pty, false) || undefined + const viewerRequest = isSessionTabSelectionRequest(opts.switchExecution?.viewerSelection) + const publishActivate = opts.switchExecution?.openPlacement?.activate ?? activate + activate = activate && !viewerRequest && (opts.switchExecution?.selectionCurrent?.() ?? true) const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', + ...(existingSurface?.openPlacement + ? { + openPlacement: { + ...existingSurface.openPlacement, + activate: + existingSurface.openPlacement.activate && + (viewerRequest ? publishActivate : activate) + } + } + : opts.switchExecution?.openPlacement + ? { + openPlacement: { + ...opts.switchExecution.openPlacement, + activate: viewerRequest ? publishActivate : activate + } + } + : {}), id: `${parentTabId}::${leafId}`, parentTabId, leafId, ptyId: livePty.pty.ptyId, incarnationId: livePty.pty.incarnationId, - title: terminal.title ?? livePty.pty.title ?? 'Terminal', + title: existingSurface?.title ?? terminal.title ?? livePty.pty.title ?? 'Terminal', ...(customTitle !== undefined ? { customTitle } : {}), // Omit only a proven root correction; an unverifiable fallback keeps its saved request. ...((terminal.savedStartupCwd ?? cwd) && (terminal.savedStartupCwd ?? cwd) !== workspace.path ? { startupCwd: terminal.savedStartupCwd ?? cwd } : {}), ...(livePty.pty.launchAgent ? { launchAgent: livePty.pty.launchAgent } : {}), - ...(!terminal.agentResumeRefused && opts.viewMode ? { viewMode: opts.viewMode } : {}), + ...(!terminal.agentResumeRefused && !terminal.agentResumeUnavailable && opts.viewMode + ? { viewMode: opts.viewMode } + : {}), parentLayout, - isActive: activate + isActive: activate || (existingSurface?.isActive ?? false) } - const tabs = placeCreatedSessionTab( - (existing?.tabs ?? []).map((candidate) => ({ - ...candidate, - ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId - ? { parentLayout } - : {}), - isActive: activate ? false : candidate.isActive - })), - tab, - afterTabId, - { afterParentGroup: opts.supportsSplitGroupPlacement !== false } - ) + const currentTabs = (existing?.tabs ?? []).map((candidate) => ({ + ...candidate, + ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId + ? { parentLayout } + : {}), + isActive: activate ? false : candidate.isActive + })) + const tabs = existingSurface + ? currentTabs.map((candidate) => (candidate.id === tab.id ? tab : candidate)) + : placeCreatedSessionTab(currentTabs, tab, afterTabId, { + afterParentGroup: opts.supportsSplitGroupPlacement !== false, + captured: capturedTabs + }) const tabGroups = buildHeadlessMobileSessionTabGroups( worktreeId, tabs, activate ? tab : null, existing?.tabGroups, - opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined + targetGroupId ? { tabId: parentTabId, groupId: targetGroupId } : undefined ) - if (opts.afterDesktopTabId) { - const group = tabGroups.find((candidate) => candidate.tabOrder.includes(parentTabId)) - if (group?.tabOrder.includes(opts.afterDesktopTabId)) { - const topLevelTabs = tabs.map((candidate) => ({ - id: candidate.type === 'terminal' ? candidate.parentTabId : candidate.id, - isPinned: candidate.isPinned === true - })) - group.tabOrder = insertTabIdIntoOrder( - group.tabOrder, - topLevelTabs, - parentTabId, - tab.isPinned === true, - opts.afterDesktopTabId - ) - } + if ( + !existingSurface && + capturedOrder && + (!opts.targetGroupId || opts.targetGroupId === sourceGroup?.id) + ) { + placeHeadlessSessionTabAtCapture(tabGroups, tabs, parentTabId, capturedOrder) } const next: RuntimeMobileSessionTabsSnapshot = { worktree: worktreeId, @@ -269,8 +261,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, // Activating the new tab must also focus its requested split group. activeGroupId: - activate && opts.targetGroupId - ? opts.targetGroupId + activate && targetGroupId && tabGroups.some((group) => group.id === targetGroupId) + ? targetGroupId : (existing?.activeGroupId ?? getHeadlessMobileSessionGroupId(worktreeId)), activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), @@ -280,7 +272,14 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext tabs } // Store adds retirement proofs; emit that snapshot because version-deduping subscribers would miss proofs from a pre-store frame. + if (activate) { + opts.switchExecution?.check() + opts.switchExecution?.selectionCurrent?.(true) + } const stored = this.storeMobileSessionSnapshot(worktreeId, next) + if (opts.switchExecution?.openPlacement) { + this.markPaneLiveTitlesDirty() + } const result = this.toMobileSessionTabsResult(stored) const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { @@ -293,8 +292,14 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext if (!created || created.type !== 'terminal') { throw new Error('terminal_handle_stale') } + if (recipe && this.ownedMobileDispatchRecipes.get(terminal.handle) === recipe) { + this.ownedMobileDispatchRecipes.delete(terminal.handle) + } return { tab: created, + ...(terminal.switchOutcomeUnknown ? { switchOutcomeUnknown: true as const } : {}), + ...(terminal.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(terminal.agentResumeRefused ? { agentResumeRefused: true as const } : {}), publicationEpoch: result.publicationEpoch, snapshotVersion: result.snapshotVersion, ...(terminal.startupCwdFallback ? { startupCwdFallback: 'workspace-root' as const } : {}) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index 245b0e985ba1..f8d21c073331 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -1,8 +1,4 @@ import { applyRemoteViewerLaunchEnv } from '../../remote-viewer-launch-env' -import type { MobileTerminalCommandOptions, MobileTerminalCommand } from './mobile-terminal-startup' -import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' -import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' -import { OrcaRuntimeWithMobileChatForkCreate } from './mobile-terminal-chat-fork-create' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' import { randomUUID } from 'node:crypto' import { getRuntimeDesktopSurface } from '../../runtime-desktop-surface' @@ -16,47 +12,11 @@ import { rendererPublicationThrottle } from '../../../window/renderer-publicatio import { deterministicAgentSessionUuid } from '../../runtime-agent-launch-resolution' import { assertMobileTerminalShellSupported } from './mobile-terminal-shell-refusal' import { anchorTabOfCreate, mainPlacedAfter } from './mobile-terminal-create-anchor' -import type { MobileTerminalCreateOptions } from './mobile-terminal-create' - -export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithMobileChatForkCreate { - protected abstract resolveMobileSessionTerminalCommand( - workspace: TerminalWorkspaceLaunchScope, - opts: MobileTerminalCommandOptions - ): Promise - - protected abstract ensurePtyBackedMobileSurfaceForRendererTab( - worktreeId: string, - tabId: string - ): RuntimeMobileSessionCreateTerminalResult | null - - protected abstract waitForMobileTerminalSurface( - worktreeId: string, - parentTabId: string, - options?: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } - ): Promise - - protected abstract isReadyMobileTerminalSurface( - surface: RuntimeMobileSessionCreateTerminalResult | null - ): boolean - - protected abstract deliverPendingStartupCommandToBareRendererPty( - worktreeId: string, - tabId: string - ): void - - protected abstract findMobileTerminalSurface( - worktreeId: string, - parentTabId: string, - options?: { requireReady?: boolean } - ): RuntimeMobileSessionCreateTerminalResult | null - - protected abstract findLiveRegisteredPtyForRendererTab( - worktreeId: string, - tabId: string - ): RuntimePtyWorktreeRecord | null - - protected abstract hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean +import { createPublishedSwitchTerminal } from './mobile-terminal-switch-publication' +import { OrcaRuntimeWithMobileTerminalCreateOwner } from './mobile-terminal-create-owner' +import type { MobileTerminalCreateOptions } from './mobile-terminal-create-options' +export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithMobileTerminalCreateOwner { protected async runCreateMobileSessionTerminal( worktreeSelector: string, opts: MobileTerminalCreateOptions = {} @@ -124,9 +84,18 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca } ) } - const win = this.getAvailableAuthoritativeWindow() - if (!win) { - return await this.createRuntimeOwnedMobileSessionTerminal( + const switchStart = + opts.resumeOrigin === 'switch' + ? { resumeProviderSession: opts.resumeProviderSession, resumeOrigin: opts.resumeOrigin } + : {} + let destinationCurrent = () => {} + let dispatched = false + let targetGroupId = opts.targetGroupId + const createOwned = ( + identity?: { tabId: string; leafId: string }, + trackPhysical = opts.switchExecution?.trackPhysical + ) => + this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, afterTabId, @@ -138,9 +107,10 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, + ...(identity ? { identity } : {}), launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, - targetGroupId: opts.targetGroupId, + targetGroupId, ...mainPlacedAfter( () => this.mainLayoutTabOfRow(worktreeId, requestedAfterTabId), afterDesktopTabId @@ -148,9 +118,57 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, launchConfig: startupCommand.launchConfig, launchRuntime: startupCommand.launchRuntime, - signal: opts.signal + ...switchStart, + ...(opts.switchExecution + ? { + createMutation: { + clientIdentity: opts.clientNavigationId ?? 'local', + id: opts.clientMutationId! + }, + keepPaneOnStartFailure: true as const, + beforePtySpawn: () => { + opts.switchExecution?.check() + destinationCurrent() + }, + onPtySpawnDispatched: () => { + dispatched = true + } + } + : {}), + signal: opts.switchExecution?.signal ?? opts.signal, + switchExecution: opts.switchExecution + ? { + ...opts.switchExecution, + trackPhysical, + check: () => { + opts.switchExecution?.check() + destinationCurrent() + } + } + : undefined } ) + if (opts.switchExecution) { + return createPublishedSwitchTerminal({ + owner: this, + windowAvailable: () => Boolean(this.getAvailableAuthoritativeWindow()), + worktreeId, + opts, + startupCommand, + afterDesktopTabId, + createOwned, + setDestinationCurrent: (check) => { + destinationCurrent = check + }, + setTargetGroup: (group) => { + targetGroupId = group + }, + wasDispatched: () => dispatched + }) + } + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await createOwned() } const rendererWebContents = win.webContents if (rendererWebContents.isDestroyed?.()) { @@ -204,6 +222,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...switchStart, ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', @@ -252,29 +271,11 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca if (!pendingSurface) { throw new Error('Timed out waiting for terminal surface after creation') } - // Hidden renderers may publish tab shells before spawn; reuse identity so later focus adopts the tab. - return await this.createRuntimeOwnedMobileSessionTerminal( - worktreeId, - opts.activate !== false, - afterTabId, - { - wslUserAct: true, - command: startupCommand.command, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - cwd, - env: startupCommand.env, - envToDelete: startupCommand.envToDelete, - startupCommandDelivery: startupCommand.startupCommandDelivery, - identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, - launchAgent: startupCommand.launchAgent, - viewMode: opts.viewMode, - targetGroupId: opts.targetGroupId, - supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, - launchConfig: startupCommand.launchConfig, - launchRuntime: startupCommand.launchRuntime, - signal: opts.signal - } - ) + // Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab. + return await createOwned({ + tabId: pendingSurface.tab.parentTabId, + leafId: pendingSurface.tab.leafId + }) } catch (error) { // Hidden-renderer publication can time out; rescue only a live-backed tab or rollback would leave a ghost (#7587). if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { @@ -302,3 +303,5 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca } } } + +export type { MobileTerminalCreateOptions } from './mobile-terminal-create-options' diff --git a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts index 9d340f0ee5b3..f479603bd13d 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts @@ -1,3 +1,9 @@ +import type { AiVaultChatForkSource } from '../../../../shared/ai-vault-resume-preparation' +import { + buildAiVaultLocalResumeStartup, + getAiVaultResumeCodexHome +} from '../../../../shared/ai-vault-resume-startup' +import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-create' import { OrcaRuntimeWithRunCreateMobileSessionTerminal } from './mobile-terminal-renderer-create' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' @@ -26,6 +32,9 @@ import { export type MobileTerminalCommandOptions = { command?: string + sessionOptions?: Record + commandOverride?: string + launchRuntime?: AgentLaunchRuntime env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -65,6 +74,59 @@ function normalizeOriginConnectionId(connectionId: string | null | undefined): s } export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { + async createSessionSwitchTerminal( + workspaceId: string, + args: { + source?: AiVaultChatForkSource + agent: TuiAgent + options?: Record + commandOverride?: string + invocationId: string + sessionId: string + afterTabId?: string + groupId?: string + execution: NonNullable + } + ) { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${workspaceId}`) + args.execution.check() + const source = args.source + const runtime = this.resolveAgentLaunchRuntime(workspace, args.agent) + const startup = source + ? buildAiVaultLocalResumeStartup({ + session: source, + settings: this.store?.getSettings(), + commandOverride: args.commandOverride, + host: { + platform: runtime.platform, + liveShell: agentLaunchQuotingShell(runtime), + codexHome: getAiVaultResumeCodexHome(source.codexHome, runtime.platform) + }, + embedCwd: false + }) + : undefined + args.execution.check() + return this.createMobileSessionTerminal(`id:${workspaceId}`, { + ...(startup + ? { + ...startup, + launchRuntime: runtime, + resumeProviderSession: startup.providerSession, + resumeOrigin: 'switch' as const, + launchAgent: args.agent + } + : { + agent: args.agent, + commandOverride: args.commandOverride, + sessionOptions: args.options + }), + afterTabId: args.afterTabId ? `agent-session:${args.sessionId}` : undefined, + targetGroupId: args.groupId, + clientMutationId: args.invocationId, + switchExecution: { ...args.execution, afterTabId: args.afterTabId } + }) + } + /** The `--resume` launch for a pane's recorded agent session, as the host window's cold restore builds it. */ protected resolveMobileSessionResumeCommand( workspace: TerminalWorkspaceLaunchScope, @@ -121,6 +183,7 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends if (opts.command || !opts.agent) { return { command: opts.command, + launchRuntime: opts.launchRuntime, env: opts.env, envToDelete: opts.envToDelete, launchConfig: opts.launchConfig, @@ -140,7 +203,17 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends ...resolveWslHostAgentLaunchInputs( resolveAgentStartupPlanInputs({ agent: opts.agent, - settings, + sessionOptions: opts.sessionOptions, + settings: + opts.commandOverride === undefined + ? settings + : { + ...settings, + agentCmdOverrides: { + ...settings.agentCmdOverrides, + [opts.agent]: opts.commandOverride + } + }, // Quote for the execution host, even when the mobile client runs another OS. platform: launchRuntime.platform, shell: agentLaunchQuotingShell(launchRuntime), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-switch-publication.ts b/src/main/runtime/terminal/launch/mobile-terminal-switch-publication.ts new file mode 100644 index 000000000000..0848b1aebc86 --- /dev/null +++ b/src/main/runtime/terminal/launch/mobile-terminal-switch-publication.ts @@ -0,0 +1,178 @@ +import { isSessionTabSelectionRequest } from '../../../../shared/session-tab-placement' +import type { OrcaRuntimeWithMobileTerminalCreateOwner } from './mobile-terminal-create-owner' +import type { MobileTerminalCreateOptions } from './mobile-terminal-create-options' +import { trackRunningAgentLaunchPane } from '../../../agent-launch/agent-launch-pane-attachment' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { AgentLaunchTabPublished } from '../../../../shared/agent-launch-tab-publication' +import type { AgentLaunchPaneVerdict } from '../../../../shared/agent-launch-pane-verdict' +import type { MobileTerminalCommand } from './mobile-terminal-startup' +import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' +import { randomUUID } from 'node:crypto' +import type { AgentSessionSwitchAuthority } from '../../../../shared/agent-session-switch-execution' + +export async function createPublishedSwitchTerminal(args: { + owner: Pick< + OrcaRuntimeWithMobileTerminalCreateOwner, + | 'publishAgentLaunchTabInMainLayout' + | 'publishAgentLaunchTab' + | 'reportAgentLaunchPaneVerdict' + | 'hasLiveTerminalForPaneKey' + | 'withdrawAgentLaunchPaneInMainLayout' + > + windowAvailable: () => boolean + worktreeId: string + opts: MobileTerminalCreateOptions + startupCommand: MobileTerminalCommand + afterDesktopTabId: string | undefined + createOwned: ( + identity: { tabId: string; leafId: string }, + trackPhysical: NonNullable + ) => Promise + setDestinationCurrent: (check: () => void) => void + setTargetGroup: (group: string) => void + wasDispatched: () => boolean +}): Promise { + const { + owner, + windowAvailable, + worktreeId, + opts, + startupCommand, + afterDesktopTabId, + createOwned, + setDestinationCurrent, + setTargetGroup, + wasDispatched + } = args + opts.switchExecution!.check() + const identity = { tabId: randomUUID(), leafId: randomUUID() } + const pane = { worktreeId, ...identity, operationId: opts.clientMutationId } + const paneKey = `${identity.tabId}:${identity.leafId}` + const running = trackRunningAgentLaunchPane({ worktreeId, paneKey }) + setDestinationCurrent(() => { + if (!running.current()) { + throw new Error('tab_not_found') + } + }) + let shown: AgentLaunchTabPublished | undefined + let publicationOwner: 'main' | 'window' = 'window' + const physicalOwners = new Set>() + const trackPhysical: NonNullable = ( + work, + recover + ) => { + // Recovery-bearing work is the receiver settlement, independent of acquisition observation. + if (recover) { + physicalOwners.add(work) + void work.then(() => physicalOwners.delete(work)).catch(() => {}) + } + return opts.switchExecution!.trackPhysical?.(work, recover) ?? work + } + const withdrawUnused = async () => { + if (shown?.created !== true || !running.current() || owner.hasLiveTerminalForPaneKey(paneKey)) { + return false + } + const withdrawn = await owner.withdrawAgentLaunchPaneInMainLayout( + pane, + running.current, + shown.withdrawalRoot, + publicationOwner + ) + if (withdrawn && running.current()) { + owner.reportAgentLaunchPaneVerdict(pane, { kind: 'withdrawn' }) + } + return withdrawn + } + try { + const request = { + ...pane, + launchAgent: startupCommand.launchAgent ?? 'codex', + viewMode: 'terminal' as const, + viewer: + isSessionTabSelectionRequest(opts.switchExecution!.viewerSelection) || + (opts.switchExecution!.selectionCurrent?.() ?? true) + ? opts.switchExecution!.viewer + : ('none' as const), + viewerSelection: opts.switchExecution!.viewerSelection, + placement: opts.switchExecution!.openPlacement?.placement ?? { + afterTabId: opts.switchExecution!.afterTabId ?? afterDesktopTabId, + groupId: opts.targetGroupId + }, + ...(opts.switchExecution!.openPlacement + ? { openPlacement: opts.switchExecution!.openPlacement } + : {}), + operationId: opts.clientMutationId + } + const mainPublication = + ORCA_LAYOUT_OWNER_IS_MAIN || !windowAvailable() + ? owner.publishAgentLaunchTabInMainLayout( + request, + opts.switchExecution!.check, + opts.switchExecution!.selectionCurrent + ) + : null + publicationOwner = mainPublication ? 'main' : 'window' + const publishing = mainPublication ?? owner.publishAgentLaunchTab(request) + if (!publishing) { + throw new Error('runtime_unavailable') + } + shown = await publishing + setTargetGroup(shown.placement.groupId) + opts.switchExecution!.check() + if (!running.current()) { + throw new Error('tab_not_found') + } + const result = await createOwned(identity, trackPhysical) + if (result.tab.parentTabId === identity.tabId && result.tab.leafId === identity.leafId) { + running.agentBound() + running.finish({ kind: 'proceed' }) + owner.reportAgentLaunchPaneVerdict(pane, { kind: 'proceed' }) + } else { + const { parentTabId, leafId } = result.tab + if (!parentTabId || !leafId) { + throw new Error('terminal_handle_stale') + } + owner.reportAgentLaunchPaneVerdict( + { + worktreeId, + tabId: parentTabId, + leafId, + operationId: opts.clientMutationId + }, + { kind: 'proceed' } + ) + const cleanup = + physicalOwners.size > 0 + ? Promise.all(physicalOwners).then(withdrawUnused) + : withdrawUnused() + void cleanup + .then((withdrawn) => { + running.finish(withdrawn ? { kind: 'withdrawn' } : { kind: 'unconfirmed' }) + }) + .catch(() => running.finish({ kind: 'unconfirmed' })) + } + return result + } catch (error) { + const live = owner.hasLiveTerminalForPaneKey(paneKey) + const withdraw = + shown?.created === true && running.current() && !live && physicalOwners.size === 0 + const verdict: AgentLaunchPaneVerdict = withdraw + ? { kind: 'withdrawn' } + : wasDispatched() && !live + ? { kind: 'unconfirmed' } + : { kind: 'proceed' } + running.finish(verdict) + if (withdraw) { + await withdrawUnused() + } else if (running.current() || live) { + owner.reportAgentLaunchPaneVerdict(pane, verdict) + } + if (physicalOwners.size > 0) { + // The receiver's real completion, not this observer's error, permits unused cleanup. + void Promise.all(physicalOwners) + .then(withdrawUnused) + .catch(() => {}) + } + throw error + } +} diff --git a/src/main/runtime/terminal/launch/terminal-create-completion.ts b/src/main/runtime/terminal/launch/terminal-create-completion.ts new file mode 100644 index 000000000000..b73463389520 --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-create-completion.ts @@ -0,0 +1,85 @@ +import { + type resolveTerminalLaunchResult, + terminalLaunchPublicFacts +} from './terminal-launch-result' +import { revealCreatedTerminal } from './terminal-create-reveal' + +import type * as dependencies from '../../orca-runtime-create-terminal-dependencies' + +export function prepareTerminalCreateCompletion(args: { + notifier: Parameters[0]['notifier'] + workspace: Parameters[0]['workspace'] + result: Parameters[0] + pty: Parameters[2] + launch: ReturnType + launchOpts: dependencies.TerminalCreateOptions + presentation: Parameters[0]['presentation'] + opts: dependencies.TerminalCreateOptions + tabId: string + leafId: string + paneKey: string + handle: string + savedStartupCwd: string + executionMetadata: () => Pick< + dependencies.RuntimeTerminalCreate, + 'executionHostId' | 'hostPlatform' | 'incarnationId' + > +}) { + const { + notifier, + workspace, + result, + pty, + launch, + launchOpts, + presentation, + opts, + tabId, + leafId, + paneKey, + handle, + savedStartupCwd, + executionMetadata + } = args + const reveal = revealCreatedTerminal({ + notifier, + workspace, + ptyId: result.id, + handle, + cwd: savedStartupCwd, + launchOpts: { + ...launchOpts, + title: pty?.title ?? launch.effectiveTitle ?? undefined, + launchAgent: pty?.launchAgent ?? undefined, + viewMode: launch.effectiveViewMode + }, + effectiveLaunchConfig: pty?.launchConfig ?? undefined, + launchToken: pty?.launchToken ?? undefined, + adoptedStablePane: launch.adoptedStablePane, + presentation, + opts, + tabId, + leafId + }) + + return { + reveal, + result: ({ surface, warning }: Awaited>) => { + return { + ...terminalLaunchPublicFacts(result, launch.adoptedStablePane, pty), + ...(result.switchOutcomeUnknown ? { switchOutcomeUnknown: true as const } : {}), + handle, + savedStartupCwd, + tabId, + paneKey, + ptyId: result.id, + worktreeId: workspace.id, + title: pty?.title ?? launch.effectiveTitle ?? null, + ...executionMetadata(), + surface, + ...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}), + ...(warning ? { warning } : {}) + } + } + } +} diff --git a/src/main/runtime/terminal/launch/terminal-create-size.ts b/src/main/runtime/terminal/launch/terminal-create-size.ts new file mode 100644 index 000000000000..b9d1607d8c2c --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-create-size.ts @@ -0,0 +1,21 @@ +import { paneSize, resolvePtyFirstSize, type PtyGridSize } from '../../../ipc/pty/pty-first-size' + +export function terminalCreateSize(args: { + workspaceId: string + targetGroupId?: string + activeGroupId?: string | null + session: Parameters[0] + opts: Parameters[1] + singleGroupSize?: PtyGridSize + groupSizes?: Record +}) { + const { workspaceId, targetGroupId, activeGroupId, session, opts, singleGroupSize, groupSizes } = + args + // The window's latest published active group is where its reveal places the tab. + const groupId = targetGroupId ?? activeGroupId ?? session?.activeGroupIdByWorktree?.[workspaceId] + // Unshown workspaces have no measured group; a single group can use the common workspace geometry. + const singleGroup = + (session?.tabGroups?.[workspaceId]?.length ?? 0) <= 1 ? singleGroupSize : undefined + const group = groupId ? groupSizes?.[groupId] : undefined + return resolvePtyFirstSize({ pane: paneSize(session, opts), group, singleGroup }) +} diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 2986bf6fec49..a908981534f6 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -1,21 +1,19 @@ -import { - recordTerminalLaunchMetadata, - resolveTerminalLaunchResult, - terminalLaunchPublicFacts -} from './terminal-launch-result' -import { revealCreatedTerminal } from './terminal-create-reveal' +import { recordTerminalLaunchMetadata, resolveTerminalLaunchResult } from './terminal-launch-result' import type { PaneSpawnOperation } from '../../../ipc/pty/pane/pane-spawn-operation' import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication' import * as dependencies from '../../orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from '../../orca-runtime-create-terminal-desktop' import { terminalCreateTeamAllocation } from './terminal-create-team-allocation' -import { createIdempotentCallback } from '../../orca-runtime-report-pty-spawn-commit' +import { + createIdempotentCallback, + reportTerminalSpawnDispatched +} from '../../orca-runtime-report-pty-spawn-commit' import { recordPtySurface, spawnSurfaceClaimSequence } from '../../pty-recorded-surface-topology' import { agentLaunchTokenFor, withAgentLaunchToken } from '../../../agent-launch/agent-launch-token' import { seedPtyLaunchTitle } from './pty-launch-title' import { migratedPaneRestoreHint } from '../../migrated-pane-restore-cwd' -import { paneSize, resolvePtyFirstSize } from '../../../ipc/pty/pty-first-size' -import { resolveLaunchCommandPaneAgent } from '../../runtime-agent-launch-resolution' +import { prepareTerminalCreateCompletion } from './terminal-create-completion' +import { terminalCreateSize } from './terminal-create-size' export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { protected abstract paneStartStatusArgs: dependencies.PaneStartArgsOf @@ -42,21 +40,19 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi } const workspace = await this.resolveTerminalCreateLaunchScope(worktreeSelector, created, opts) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) + launchOpts.switchExecution?.check() const launchTitleAt = { sequence: this.nextTitleObservationSequence(), epochMs: Date.now() } let cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path const session = this.getWorkspaceSessionForWorktree(workspace.id) - // The window's latest published active group is where its reveal places the tab. - const groupId = - launchOpts.targetGroupId ?? - this.mobileSessionTabsByWorktree.get(workspace.id)?.activeGroupId ?? - session?.activeGroupIdByWorktree?.[workspace.id] - // Unshown workspaces have no measured group; a single group can use the common workspace geometry. - const singleGroup = - (session?.tabGroups?.[workspace.id]?.length ?? 0) <= 1 - ? this.singleGroupWorkspaceGridSize - : undefined - const group = groupId ? this.terminalGroupGridSizes?.[groupId] : undefined - const initialSize = resolvePtyFirstSize({ pane: paneSize(session, opts), group, singleGroup }) + const initialSize = terminalCreateSize({ + workspaceId: workspace.id, + targetGroupId: launchOpts.targetGroupId, + activeGroupId: this.mobileSessionTabsByWorktree.get(workspace.id)?.activeGroupId, + session, + opts, + singleGroupSize: this.singleGroupWorkspaceGridSize, + groupSizes: this.terminalGroupGridSizes + }) let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() let { tabId, leafId, paneKey } = dependencies.allocateTerminalPaneIdentity(launchOpts) @@ -114,8 +110,15 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi } try { const { launchAgent } = launchOpts - launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) result = await dependencies.spawnInAdmittedPane(this.store, ptyController, { + keepOnFailure: launchOpts.keepPaneOnStartFailure, + beforeAdmission: launchOpts.beforePtySpawn, + beforeSpawn: reportTerminalSpawnDispatched(launchOpts, () => ({ + tabId, + leafId, + launchConfig: effectiveLaunchConfig, + launchAgent + })), ...initialSize, cwd, command: sequencedStartupCommand @@ -131,7 +134,9 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), - resumeProviderSession: launchOpts.resumeProviderSession, + ...(launchOpts.resumeProviderSession + ? { resumeProviderSession: launchOpts.resumeProviderSession } + : {}), ...(launchOpts.resumeOrigin ? { resumeOrigin: launchOpts.resumeOrigin } : {}), telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, @@ -184,28 +189,36 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi if (launchOpts.signal?.aborted) { throw new Error('client_disconnected') } - const adoptedBeforeLaunch = await ptyController.adoptStablePane?.({ - ...initialSize, - cwd, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId, - leafId, - spawnFresh, - onOutcome: (outcome, operation) => { - claimedStablePaneCreate?.acceptOutcome?.(outcome, operation) - teamAllocation.acceptOutcome(outcome) - }, - signal: launchOpts.signal - }) - if (adoptedBeforeLaunch) { - result = dependencies.materializedStablePaneReply(adoptedBeforeLaunch, preAllocatedHandle) - Object.assign(result, this.getTerminalLaunchResult(result.id)) - } else if (!ptyController.adoptStablePane) { - result = await spawnFresh({ mustExist: false }) - teamAllocation.acceptFresh(result) + const acquire = async () => { + const adoptedBeforeLaunch = await ptyController.adoptStablePane?.({ + ...initialSize, + cwd, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId, + leafId, + spawnFresh, + onOutcome: (outcome, operation) => { + claimedStablePaneCreate?.acceptOutcome?.(outcome, operation) + teamAllocation.acceptOutcome(outcome) + }, + switchExecution: launchOpts.switchExecution, + signal: launchOpts.signal + }) + if (adoptedBeforeLaunch) { + result = dependencies.materializedStablePaneReply( + adoptedBeforeLaunch, + preAllocatedHandle + ) + Object.assign(result, this.getTerminalLaunchResult(result.id)) + } else if (!ptyController.adoptStablePane) { + result = await spawnFresh({ mustExist: false }) + teamAllocation.acceptFresh(result) + } } + const physical = acquire() + await (launchOpts.switchExecution?.trackPhysical?.(physical) ?? physical) if (!result.stablePaneOwner) { reportPtySpawnCommitted() } @@ -218,7 +231,9 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi launchToken }) if (launch.surface) { - ;({ terminalHandle: preAllocatedHandle, tabId, leafId } = launch.surface) + tabId = launch.surface.tabId + leafId = launch.surface.leafId + preAllocatedHandle = launch.surface.terminalHandle ?? preAllocatedHandle paneKey = dependencies.makePaneKey(tabId, leafId) } this.assertCreatedPtyRegistrationAllowed(result.id, result.incarnationId) @@ -243,11 +258,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi this.setPtyManagementTitleFromObservedTitle(pty, title, at) ) } - recordTerminalLaunchMetadata(pty, result, launch) + recordTerminalLaunchMetadata(pty, result, launch, launchOpts.command) if (!launch.adoptedStablePane) { - pty.launchCommandAgent = result.agentResumeRefused - ? null - : resolveLaunchCommandPaneAgent(launchOpts.command) this.rememberSavedTerminalLaunchResult(result.id, result) } recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) @@ -264,41 +276,25 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ...(savedStartupCwd !== workspace.path ? { startupCwd: savedStartupCwd } : {}) }) } - const reveal = revealCreatedTerminal({ + const completion = prepareTerminalCreateCompletion({ notifier: this.notifier, workspace, - ptyId: result.id, - handle, - cwd: savedStartupCwd, - launchOpts: { - ...launchOpts, - title: pty?.title ?? launch.effectiveTitle ?? undefined, - launchAgent: pty?.launchAgent ?? undefined, - viewMode: launch.effectiveViewMode - }, - effectiveLaunchConfig: pty?.launchConfig ?? undefined, - launchToken: pty?.launchToken ?? undefined, - adoptedStablePane: launch.adoptedStablePane, + result, + pty, + launch, + launchOpts, presentation, opts, tabId, - leafId - }) - const { surface, warning } = reveal instanceof Promise ? await reveal : reveal - return { - ...terminalLaunchPublicFacts(result, launch.adoptedStablePane, pty), + leafId, + paneKey, handle, savedStartupCwd, - tabId, - paneKey, - ptyId: result.id, - worktreeId: workspace.id, - title: pty?.title ?? launch.effectiveTitle ?? null, - ...this.getPtyExecutionHostMetadata(result.id), - surface, - ...(result.startupCwdFallback ? { startupCwdFallback: result.startupCwdFallback } : {}), - ...(warning ? { warning } : {}) - } + executionMetadata: () => this.getPtyExecutionHostMetadata(result.id) + }) + const revealed = + completion.reveal instanceof Promise ? await completion.reveal : completion.reveal + return completion.result(revealed) } finally { teamAllocation.release() releaseStablePaneCreate() diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts index 05a293f4d8ad..1a4e689f7e3e 100644 --- a/src/main/runtime/terminal/launch/terminal-launch-result.ts +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -3,7 +3,10 @@ import { admitStablePaneAdoption } from '../../runtime-terminal-pane-identity' import type { RuntimePtyController } from '../../runtime-pty-controller-contract' import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' -import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' +import { + copySleepingAgentLaunchConfig, + resolveLaunchCommandPaneAgent +} from '../../runtime-agent-launch-resolution' type TerminalSpawnResult = Awaited>> @@ -20,8 +23,17 @@ export function resolveTerminalLaunchResult( result.isReattach === true || result.agentSessionEnsure?.disposition === 'adopted' const refused = Boolean(result.agentResumeRefused) + const requestedRecipeUnavailable = refused || result.agentResumeUnavailable || adoptedStablePane const owner = result.stablePaneOwner const surface = + (result.switchRecoveredSurface + ? { + ...result.switchRecoveredSurface, + terminalHandle: + result.agentSessionEnsure?.owner.surface.terminalHandle ?? + result.stablePaneOwner?.handle + } + : undefined) ?? result.agentSessionEnsure?.owner.surface ?? (owner ? { terminalHandle: owner.handle, tabId: owner.tabId, leafId: owner.leafId } : undefined) return { @@ -29,13 +41,13 @@ export function resolveTerminalLaunchResult( surface, launchAgent: refused ? undefined - : (result.launchAgent ?? (adoptedStablePane ? undefined : requested.launchAgent)), + : (result.launchAgent ?? (requestedRecipeUnavailable ? undefined : requested.launchAgent)), launchConfig: refused ? undefined - : (result.launchConfig ?? (adoptedStablePane ? undefined : requested.launchConfig)), + : (result.launchConfig ?? (requestedRecipeUnavailable ? undefined : requested.launchConfig)), launchToken: refused ? undefined - : (result.launchToken ?? (adoptedStablePane ? undefined : requested.launchToken)), + : (result.launchToken ?? (requestedRecipeUnavailable ? undefined : requested.launchToken)), effectiveTitle: refused || adoptedStablePane ? null : requested.title, effectiveViewMode: refused || adoptedStablePane ? undefined : requested.viewMode } @@ -44,7 +56,8 @@ export function resolveTerminalLaunchResult( export function recordTerminalLaunchMetadata( pty: RuntimePtyWorktreeRecord, result: TerminalSpawnResult, - launch: ReturnType + launch: ReturnType, + command: TerminalCreateOptions['command'] ): void { if (launch.adoptedStablePane) { // A lower owner can supply facts before this runtime has a launch record. @@ -63,6 +76,10 @@ export function recordTerminalLaunchMetadata( pty.launchToken = launch.launchToken ?? null pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null pty.launchAgent = launch.launchAgent ?? null + pty.launchCommandAgent = + result.agentResumeRefused || result.agentResumeUnavailable + ? null + : resolveLaunchCommandPaneAgent(command) } } diff --git a/src/main/runtime/terminal/lifecycle/bind-first-registration-census.test.ts b/src/main/runtime/terminal/lifecycle/bind-first-registration-census.test.ts new file mode 100644 index 000000000000..831da7cbb110 --- /dev/null +++ b/src/main/runtime/terminal/lifecycle/bind-first-registration-census.test.ts @@ -0,0 +1,132 @@ +// Census every registration path and its preceding binding write; new paths must establish pane ownership before registering (L80). +import { readFileSync } from 'node:fs' +import path from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const ROOT = path.resolve(__dirname, '../../../../..') + +/** Create path → the registration site and the binding write that precedes it in the same function. */ +const CREATE_PATHS = [ + { + // Both start lanes (window spawn, agent launch included, and runtime spawn) commit here. + path: 'spawn commit, a new terminal', + file: 'src/main/ipc/pty/spawn-commit-assembly.ts', + bindsWith: 'savePtySpawnPaneBinding(' + }, + { + // Adoption reuses the live owner surface; switch reconciliation binds it before registration. + path: 'spawn commit, an adopted live owner', + file: 'src/main/ipc/pty/spawn-commit-adopted-session.ts', + bindsWith: 'adopted.owner' + }, + { + path: 'pane adoption of a live session', + file: 'src/main/ipc/pty/pane/complete-pane-adoption.ts', + bindsWith: 'persistAdmittedStablePaneBinding(' + }, + { + path: 'runtime create: CLI terminal.create, orchestration and federated workers, provisioning', + file: 'src/main/runtime/terminal/launch/terminal-create.ts', + bindsWith: 'spawnInAdmittedPane(' + }, + { + path: 'runtime-owned mobile create and restored-pane start', + file: 'src/main/runtime/terminal/launch/mobile-terminal-create-reconciliation.ts', + bindsWith: 'persistFreshPaneBinding(' + }, + { + path: 'CLI split of a terminal', + file: 'src/main/runtime/terminal/launch/terminal-split-surface.ts', + bindsWith: 'spawnInAdmittedPane(' + }, + { + path: 'split main starts in its own layout', + file: 'src/main/runtime/terminal/launch/terminal-split-in-main.ts', + bindsWith: 'spawnInAdmittedPane(' + } +] as const + +/** The registration call itself, not a site that registers a terminal. */ +const DEFINITION = 'src/main/ipc/pty/pane/spawn-registration.ts' + +function calleeName(call: ts.CallExpression): string | null { + const callee = call.expression + if (ts.isIdentifier(callee)) { + return callee.text + } + return ts.isPropertyAccessExpression(callee) ? callee.name.text : null +} + +function enclosingFunction(node: ts.Node): ts.Node | null { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) { + return current + } + } + return null +} + +/** Each runtime registration in the file, with its function's source before the call. */ +function registrations(file: string): { line: number; textBefore: string }[] { + const text = readFileSync(path.join(ROOT, file), 'utf8') + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) + const found: { line: number; textBefore: string }[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = calleeName(node) + if ( + name === 'registerPersistedPtySpawn' || + (name === 'registerPty' && ts.isPropertyAccessExpression(node.expression)) + ) { + const fn = enclosingFunction(node) + found.push({ + line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1, + textBefore: fn ? text.slice(fn.getStart(), node.getStart()) : '' + }) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return found +} + +function productionFiles(dir: string): string[] { + const out: string[] = [] + const walk = (current: string): void => { + for (const entry of ts.sys.readDirectory(current, ['.ts'], undefined, undefined)) { + const relative = path.relative(ROOT, entry).split(path.sep).join('/') + if ( + !/\.(test|spec)\.ts$/.test(relative) && + !/(fixture|harness)/.test(relative) && + relative !== DEFINITION + ) { + out.push(relative) + } + } + } + walk(path.join(ROOT, dir)) + return out +} + +describe('bind-first registration census (L80)', () => { + it('lists every runtime registration site with the binding write before it', () => { + const listed = new Set(CREATE_PATHS.map((entry) => entry.file)) + const unlisted = productionFiles('src/main').filter( + (file) => !listed.has(file) && registrations(file).length > 0 + ) + expect(unlisted).toEqual([]) + for (const file of listed) { + const binds = CREATE_PATHS.filter((entry) => entry.file === file).map((e) => e.bindsWith) + const sites = registrations(file) + expect(sites.length, file).toBeGreaterThan(0) + // Every registration in the file runs after one of its paths' binding writes, in its function. + const unbound = sites.filter((site) => !binds.some((bind) => site.textBefore.includes(bind))) + expect( + unbound.map((site) => `${file}:${site.line}`), + `registers before binding (${binds.join(', ')})` + ).toEqual([]) + } + }) +}) diff --git a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts index 02f7eb5ba4ac..a76192c7529f 100644 --- a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts +++ b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts @@ -133,7 +133,12 @@ export abstract class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends this.terminalSpawnCommandsByPtyId.delete(ptyId) this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) - this.invalidatePtyIncarnationHandle(ptyId) + const retained = this.handleByPtyIncarnation.get(ptyId) + const retainedLeaf = retained ? this.leaves.get(retained.leafKey) : null + // A certified exit may lose its PTY record while its readable pane still owns the handle. + if (this.getPtyLivenessVerdict(ptyId)?.status !== 'exited' || !retainedLeaf) { + this.invalidatePtyIncarnationHandle(ptyId) + } this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) diff --git a/src/main/runtime/terminal/output/provider-buffer-capture.ts b/src/main/runtime/terminal/output/provider-buffer-capture.ts index b221748d0ffc..f26af77ae802 100644 --- a/src/main/runtime/terminal/output/provider-buffer-capture.ts +++ b/src/main/runtime/terminal/output/provider-buffer-capture.ts @@ -38,6 +38,27 @@ export abstract class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaR rows: number }): Promise + protected async readRenderedScreen( + ptyId: string, + read: RuntimeTerminalRead, + opts: { limit?: number } = {} + ): Promise { + const visibleState = await this.readVisibleTerminalState(ptyId) + const projection = + visibleState ?? + (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) + if (projection.lines.length === 0) { + return { ...read, source: 'screen-unavailable' } + } + return buildVisibleSnapshotReadFallback( + read, + projection.lines, + opts.limit, + projection.draft, + projection.tailRows + ) + } + protected async captureProviderTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number }, diff --git a/src/main/runtime/terminal/query/pty-terminal-summary.ts b/src/main/runtime/terminal/query/pty-terminal-summary.ts index 3e152c8652c8..f0253d486338 100644 --- a/src/main/runtime/terminal/query/pty-terminal-summary.ts +++ b/src/main/runtime/terminal/query/pty-terminal-summary.ts @@ -254,14 +254,8 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime protected assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void { const runtimePty = this.getLivePtyForHandle(handle) - if (runtimePty) { - if (runtimePty.pty.ptyId !== expectedPtyId) { - throw new Error('terminal_handle_stale') - } - return - } - const { leaf } = this.getLiveLeafForHandle(handle) - if (leaf.ptyId !== expectedPtyId) { + const ptyId = runtimePty ? runtimePty.pty.ptyId : this.getLiveLeafForHandle(handle).leaf.ptyId + if (ptyId !== expectedPtyId) { throw new Error('terminal_handle_stale') } } @@ -289,6 +283,15 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime const existingHandle = this.handleByLeafKey.get(leafKey) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.worktreeId === leaf.worktreeId && + existingRecord.ptyId && + !leaf.ptyId && + this.getPtyLivenessVerdict(existingRecord.ptyId)?.status === 'exited' + ) { + return existingHandle + } if ( existingRecord && existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index 2c2c24170ea1..b348a703e9b9 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -1,36 +1,22 @@ +import { + captureTerminalSurfaceCloseIdentity, + captureAddressedTerminalPresentation +} from '../../workspace-session-terminal-tab-retirement-identity' import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import type { OrcaRuntimeWithCreateTerminal } from '../launch/terminal-create' -import { OrcaRuntimeWithRestoredHandlePaneStart } from '../surface/restored-handle-pane-start' -import type { - RuntimeTerminalRead, - RuntimeTerminalResolvePane, - RuntimeTerminalShow -} from '../../../../shared/runtime-types' -import type { - RuntimeTerminalCreate, - RuntimeTerminalSummary -} from '../../../../shared/runtime-terminal-contracts' -import type { - RuntimeProviderSnapshotReadOptions, - TerminalHandleRecord -} from '../../runtime-terminal-contracts' +import type * as Read from '../../../../shared/runtime-types' +import type { RuntimeTerminalResolvePane } from '../../../../shared/runtime-types' +import type { RuntimeTerminalCreate } from '../../../../shared/runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { - buildVisibleSnapshotReadFallback, - labelTerminalReadSource, - readTerminalTail -} from '../../terminal-tail-read' -import { getTerminalState } from '../../terminal-wait-results' -import { readStructuredWorkerTerminal } from '../../structured-worker-terminal-read' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { OrcaRuntimeWithReadTerminalPane } from './terminal-pane-read' -export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithRestoredHandlePaneStart { - protected abstract assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void +export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithReadTerminalPane { abstract createTerminal( ...args: Parameters ): ReturnType @@ -44,21 +30,13 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith protected abstract buildPtyTerminalSummary( pty: RuntimePtyWorktreeRecord, worktreesById: Map - ): RuntimeTerminalSummary - protected abstract getLiveLeafForHandle(handle: string): { - record: TerminalHandleRecord - leaf: RuntimeLeafRecord - } + ): Read.RuntimeTerminalSummary + protected abstract buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map, provenLivePtyIds?: ReadonlySet | null - ): RuntimeTerminalSummary - protected abstract readPtyTerminal( - handle: string, - pty: RuntimePtyWorktreeRecord, - opts?: { cursor?: number; limit?: number } - ): RuntimeTerminalRead + ): Read.RuntimeTerminalSummary resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns @@ -83,15 +61,68 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith // Why: pane coordinates restored by a paired client must not cross workspace ownership. throw new Error('terminal_not_found') } + const holders = + worktreeId && parsed + ? this.getWorkspaceSessionHostIdsForTab(worktreeId, parsed.tabId).map((hostId) => ({ + hostId, + identity: captureTerminalSurfaceCloseIdentity( + this.store?.getWorkspaceSession?.(hostId), + worktreeId, + parsed.tabId, + parsed.leafId + ) + })) + : [] + const rowIdentity = holders[0]?.identity + const executionPtyId = pty?.ptyId ?? record?.ptyId ?? leaf?.ptyId ?? null + const retained = executionPtyId ? this.handleByPtyIncarnation.get(executionPtyId) : null + const execution = this.getPtyExecutionHostMetadata(executionPtyId) + if ( + !execution.incarnationId && + retained?.handle === handle && + executionPtyId && + this.getPtyLivenessVerdict(executionPtyId)?.status === 'exited' + ) { + execution.incarnationId = retained.incarnationId ?? undefined + } + const identity = + parsed && worktreeId + ? captureAddressedTerminalPresentation({ + runtimeId: this.runtimeId, + record, + currentPty: pty, + paneOfPty: (current) => this.paneOfPty(current), + leaf, + row: rowIdentity, + address: { worktreeId, ...parsed }, + ptyId: executionPtyId, + incarnationId: execution.incarnationId + }) + : null return { + ...(identity ? { presentation: { identity, holders } } : {}), handle, tabId: parsed?.tabId ?? record?.tabId ?? '', leafId: parsed?.leafId ?? record?.leafId ?? '', - ptyId: record?.ptyId ?? null, + ptyId: executionPtyId, connected: pty?.connected === true, ...(worktreeId ? { worktreeId } : {}), - ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) + ...execution + } + } + + /** Actual placement of an exact live child; a launch claim's original coordinates can be stale. */ + resolveLiveTerminalSurface(ptyId: string, incarnationId: string | undefined) { + const pty = this.ptysById.get(ptyId) + if (!pty?.connected || !incarnationId || pty.incarnationId !== incarnationId) { + return null } + const pane = this.paneOfPty(pty) + const parsed = pane.paneKey ? parsePaneKey(pane.paneKey) : null + if (!parsed) { + return null + } + return { ...parsed, worktreeId: pty.worktreeId, terminalHandle: this.issuePtyHandle(pty) } } /** Whether the caller's handle still names the pane it asks to recover. */ @@ -187,7 +218,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith return recovery } - async showTerminal(handle: string): Promise { + async showTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { const worktreesById = await this.getResolvedWorktreeMap() @@ -229,79 +260,4 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith ...(agentWait !== undefined ? { agentWait } : {}) } } - - async readTerminal( - handle: string, - opts: { cursor?: number; limit?: number; screen?: boolean } = {}, - providerSnapshot: RuntimeProviderSnapshotReadOptions = {} - ): Promise { - // Structured workers have no PTY/leaf; read their journal before PTY lookup can falsely call the handle stale. - const structured = await readStructuredWorkerTerminal({ - handle, - db: this.getOrchestrationDbIfAvailable?.() ?? null, - ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }), - ...(opts.limit === undefined ? {} : { limit: opts.limit }) - }) - if (structured) { - // `screen` asks for a rendered grid; there is none, and the journal is the whole record. - return { ...structured, source: opts.screen ? 'screen-unavailable' : 'stream' } - } - // Switch point: a saved pane main holds with no terminal yet starts first (once, shared). - const starting = ORCA_LAYOUT_OWNER_IS_MAIN ? this.startRestoredHandlePane(handle) : null - if (starting) { - const target = await starting - return { ...(await this.readTerminal(target, opts, providerSnapshot)), handle } - } - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const read = this.readPtyTerminal(handle, pty.pty, opts) - const visibleRead = opts.screen - ? await this.readRenderedScreen(pty.pty.ptyId, read, opts) - : await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot) - this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - return labelTerminalReadSource(visibleRead) - } - - const { leaf } = this.getLiveLeafForHandle(handle) - const read = readTerminalTail({ - handle, - status: getTerminalState(leaf), - previewLines: leaf.tailBuffer, - completedLines: leaf.tailTranscriptBuffer, - partialLine: leaf.tailPartialLine, - completedLineCount: leaf.tailLinesTotal, - bufferTruncated: leaf.tailTruncated, - cursor: opts.cursor, - limit: opts.limit - }) - if (!leaf.ptyId) { - return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' } - } - const visibleRead = opts.screen - ? await this.readRenderedScreen(leaf.ptyId, read, opts) - : await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot) - this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) - return labelTerminalReadSource(visibleRead) - } - - protected async readRenderedScreen( - ptyId: string, - read: RuntimeTerminalRead, - opts: { limit?: number } = {} - ): Promise { - const visibleState = await this.readVisibleTerminalState(ptyId) - const projection = - visibleState ?? - (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) - if (projection.lines.length === 0) { - return { ...read, source: 'screen-unavailable' } - } - return buildVisibleSnapshotReadFallback( - read, - projection.lines, - opts.limit, - projection.draft, - projection.tailRows - ) - } } diff --git a/src/main/runtime/terminal/recovery/terminal-pane-read.ts b/src/main/runtime/terminal/recovery/terminal-pane-read.ts new file mode 100644 index 000000000000..71b54cbd2c67 --- /dev/null +++ b/src/main/runtime/terminal/recovery/terminal-pane-read.ts @@ -0,0 +1,84 @@ +import { OrcaRuntimeWithRestoredHandlePaneStart } from '../surface/restored-handle-pane-start' +import type * as Read from '../../../../shared/runtime-types' +import type { + RuntimeProviderSnapshotReadOptions, + TerminalHandleRecord +} from '../../runtime-terminal-contracts' +import type { + RuntimeLeafRecord, + RuntimePtyWorktreeRecord +} from '../../runtime-terminal-state-records' +import { labelTerminalReadSource, readTerminalTail } from '../../terminal-tail-read' +import { getTerminalState } from '../../terminal-wait-results' +import { readStructuredWorkerTerminal } from '../../structured-worker-terminal-read' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' + +export abstract class OrcaRuntimeWithReadTerminalPane extends OrcaRuntimeWithRestoredHandlePaneStart { + protected abstract assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void + + protected abstract getLiveLeafForHandle(handle: string): { + record: TerminalHandleRecord + leaf: RuntimeLeafRecord + } + + protected abstract readPtyTerminal( + handle: string, + pty: RuntimePtyWorktreeRecord, + opts?: { cursor?: number; limit?: number } + ): Read.RuntimeTerminalRead + + async readTerminal( + handle: string, + opts: { cursor?: number; limit?: number; screen?: boolean } = {}, + providerSnapshot: RuntimeProviderSnapshotReadOptions = {} + ): Promise { + // Before the PTY lookup, because a structured worker has no PTY and no leaf: without this the + // only peer read verb answers `terminal_handle_stale` for a perfectly live worker. + const structured = await readStructuredWorkerTerminal({ + handle, + db: this.getOrchestrationDbIfAvailable?.() ?? null, + ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }), + ...(opts.limit === undefined ? {} : { limit: opts.limit }) + }) + if (structured) { + // `screen` asks for a rendered grid; there is none, and the journal is the whole record. + return { ...structured, source: opts.screen ? 'screen-unavailable' : 'stream' } + } + // Switch point: a saved pane main holds with no terminal yet starts first (once, shared). + const starting = ORCA_LAYOUT_OWNER_IS_MAIN ? this.startRestoredHandlePane(handle) : null + if (starting) { + const target = await starting + return { ...(await this.readTerminal(target, opts, providerSnapshot)), handle } + } + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const read = this.readPtyTerminal(handle, pty.pty, opts) + const visibleRead = opts.screen + ? await this.readRenderedScreen(pty.pty.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot) + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + return labelTerminalReadSource(visibleRead) + } + + const { leaf } = this.getLiveLeafForHandle(handle) + const read = readTerminalTail({ + handle, + status: getTerminalState(leaf), + previewLines: leaf.tailBuffer, + completedLines: leaf.tailTranscriptBuffer, + partialLine: leaf.tailPartialLine, + completedLineCount: leaf.tailLinesTotal, + bufferTruncated: leaf.tailTruncated, + cursor: opts.cursor, + limit: opts.limit + }) + if (!leaf.ptyId) { + return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' } + } + const visibleRead = opts.screen + ? await this.readRenderedScreen(leaf.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot) + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) + return labelTerminalReadSource(visibleRead) + } +} diff --git a/src/main/runtime/workspace-layout-stream.ts b/src/main/runtime/workspace-layout-stream.ts index 674db9ad62c9..de968ad87caf 100644 --- a/src/main/runtime/workspace-layout-stream.ts +++ b/src/main/runtime/workspace-layout-stream.ts @@ -17,7 +17,7 @@ import type { WorkspaceLayoutStreamEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' import type { RuntimeStore } from './runtime-store-contract' -import { withLiveTitles } from './published-pane-live-titles' +import { paneLiveTitlesOf, withLiveTitles } from './published-pane-live-titles' import { paneStartFactsOf, withPaneStartFacts, type PaneFactSources } from './pane-start-statuses' import { workspaceLayoutFrameObserver, @@ -79,6 +79,7 @@ export class WorkspaceLayoutStream { store: () => StreamStore | null homeHostId: (key: string) => ExecutionHostId | null liveTitle: (ptyId: string) => string | null + withOpenPlacements?: (layout: PublishedWorkspaceLayout) => PublishedWorkspaceLayout } ) { this.projection = new WorkspaceLayoutProjection(deps) @@ -230,14 +231,6 @@ export class WorkspaceLayoutStream { } } - private liveTitlesOf(layout: PublishedWorkspaceLayout): (string | null)[] { - return layout.tabs.flatMap((tab) => - (tab.terminal?.panes ?? []).map((pane) => - pane.ptyId ? this.deps.liveTitle(pane.ptyId) : null - ) - ) - } - private reconcile(reproject: boolean): void { const next = reproject ? this.projection.project() : this.projection.published const removed: string[] = [] @@ -250,16 +243,21 @@ export class WorkspaceLayoutStream { const shown = new Map() const changed: string[] = [] for (const [key, { layout, json }] of next) { - const titles = this.liveTitlesOf(layout) + const titles = paneLiveTitlesOf(layout, this.deps) // Switch point: a pane's start facts are live state, laid over as its title is. const starts = ORCA_LAYOUT_OWNER_IS_MAIN ? paneStartFactsOf(layout, this.deps) : null - const liveTitles = JSON.stringify(starts ? [titles, starts] : titles) + const placed = this.deps.withOpenPlacements?.(layout) ?? layout + const liveTitles = JSON.stringify([ + titles, + starts, + placed.tabs.map((tab) => tab.openPlacement) + ]) const previous = before.get(key) if (previous?.json === json && previous.liveTitles === liveTitles) { shown.set(key, previous) continue } - const shownLayout = withLiveTitles(layout, titles) + const shownLayout = withLiveTitles(placed, titles) const laid = starts ? withPaneStartFacts(shownLayout, starts) : shownLayout shown.set(key, { json, liveTitles, layout: laid }) changed.push(key) @@ -311,7 +309,8 @@ export class WorkspaceLayoutStream { private freshLayouts(): Map { const fresh = new Map() for (const [key, { layout }] of this.projection.projectFresh()) { - const titled = withLiveTitles(layout, this.liveTitlesOf(layout)) + const placed = this.deps.withOpenPlacements?.(layout) ?? layout + const titled = withLiveTitles(placed, paneLiveTitlesOf(layout, this.deps)) const starts = ORCA_LAYOUT_OWNER_IS_MAIN ? paneStartFactsOf(layout, this.deps) : null fresh.set(key, starts ? withPaneStartFacts(titled, starts) : titled) } diff --git a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts index c0ffec68fba6..25d085c5a94f 100644 --- a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts +++ b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts @@ -1,3 +1,7 @@ +import type { PtyPaneOf } from './terminal/surface/pty-pane-query' +import type { TerminalHandleRecord } from './runtime-terminal-contracts' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection' @@ -156,3 +160,66 @@ export function captureAcknowledgedTerminalTabRetirement( return { matches, hasPersistedTab: remaining !== null } } } + +export { captureTerminalSurfaceIdentity as captureTerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' + +/** A captured source narrows the existing authenticated handle; it cannot address another owner. */ +export function isOriginalTerminalHandle(args: { + runtimeId: string + record: TerminalHandleRecord | undefined + currentPty: RuntimePtyWorktreeRecord | null | undefined + paneOfPty: PtyPaneOf + leaf: RuntimeLeafRecord | undefined + source: TerminalSurfaceCloseIdentity + incarnation: string | undefined +}): boolean { + const { runtimeId, record, currentPty, paneOfPty, leaf, source, incarnation } = args + return ( + !!incarnation && + !!source.ptyId && + source.incarnationId === incarnation && + !!record && + record.runtimeId === runtimeId && + record.worktreeId === source.worktreeId && + record.ptyId === source.ptyId && + (!currentPty || + (currentPty.incarnationId === incarnation && currentPty.worktreeId === source.worktreeId)) && + (!leaf || + (leaf.worktreeId === source.worktreeId && (!leaf.ptyId || leaf.ptyId === source.ptyId))) && + ((record.tabId === source.tabId && record.leafId === source.leafId) || + (!!currentPty && paneOfPty(currentPty).paneKey === `${source.tabId}:${source.leafId}`)) + ) +} + +/** Derives presentation from the authenticated addressed owner, retaining absent-row evidence. */ +export function captureAddressedTerminalPresentation(args: { + runtimeId: string + record: TerminalHandleRecord | undefined + currentPty: RuntimePtyWorktreeRecord | null | undefined + paneOfPty: PtyPaneOf + leaf: RuntimeLeafRecord | null | undefined + row: TerminalSurfaceCloseIdentity | null | undefined + address: { worktreeId: string; tabId: string; leafId: string } + ptyId: string | null + incarnationId: string | null | undefined +}): TerminalSurfaceCloseIdentity | null { + const { row, address, leaf, ptyId, incarnationId } = args + const source: TerminalSurfaceCloseIdentity = { + ...address, + createdAt: row?.createdAt ?? null, + generation: row?.generation ?? 0, + hasLeaf: !!leaf || row?.hasLeaf === true, + ptyId, + incarnationId: incarnationId ?? null + } + return (!row?.ptyId || row.ptyId === ptyId) && + (!row?.incarnationId || row.incarnationId === incarnationId) && + isOriginalTerminalHandle({ + ...args, + source, + leaf: leaf ?? undefined, + incarnation: incarnationId ?? undefined + }) + ? source + : null +} diff --git a/src/main/runtime/workspaces/startup/workspace-agent-startup.ts b/src/main/runtime/workspaces/startup/workspace-agent-startup.ts new file mode 100644 index 000000000000..e01e3463d090 --- /dev/null +++ b/src/main/runtime/workspaces/startup/workspace-agent-startup.ts @@ -0,0 +1,82 @@ +import { OrcaRuntimeWithListManagedWorktrees } from '../managed-workspace-operations' +import type { Repo } from '../../../../shared/repo-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' +import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' +import { + buildWorktreeStartupForAgent, + buildWorktreeStartupForDraft, + resolveWorktreeStartupDraftAgent +} from '../../runtime-worktree-agent-startup' + +export abstract class OrcaRuntimeWithBuildWorktreeStartup extends OrcaRuntimeWithListManagedWorktrees { + protected abstract toAgentSessionOptions( + preferences: AgentLaunchPreferences | undefined + ): Record | undefined + + /** Use buildStartupForDraft's selection so launches can name the agent before creation. */ + async resolveStartupDraftAgent(repo: Repo, requestedAgent?: TuiAgent): Promise { + if (!this.store) { + return null + } + return resolveWorktreeStartupDraftAgent({ + repo, + settings: this.store.getSettings(), + ...(requestedAgent ? { requestedAgent } : {}) + }) + } + + protected async buildStartupForDraft( + repo: Repo, + draft: string, + requestedAgent?: TuiAgent, + launchSource?: string + ): ReturnType { + if (!this.store) { + return null + } + return buildWorktreeStartupForDraft({ + repo, + draft, + ...(requestedAgent ? { requestedAgent } : {}), + ...(launchSource ? { launchSource } : {}), + settings: this.store.getSettings(), + getLaunchRuntime: (agent) => this.resolveStartupAgentLaunchRuntime(repo, agent) + }) + } + + protected buildStartupForAgent( + repo: Repo, + agent: TuiAgent, + prompt: string | undefined, + launchPreferences?: AgentLaunchPreferences, + launchInputs?: { + agentArgs?: string | null + extraAgentArgs?: string + suppressStartupUpdatePrompt?: boolean + launchSource?: string + onPromptCarry?: (carried: boolean) => void + /** The existing workspace it starts in; absent is a new workspace of `repo`. */ + launchScope?: TerminalWorkspaceLaunchScope + } + ): ReturnType { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return buildWorktreeStartupForAgent({ + repo, + agent, + ...(prompt !== undefined ? { prompt } : {}), + ...(launchPreferences ? { launchPreferences } : {}), + ...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}), + ...(launchInputs?.extraAgentArgs ? { extraAgentArgs: launchInputs.extraAgentArgs } : {}), + ...(launchInputs?.suppressStartupUpdatePrompt ? { suppressStartupUpdatePrompt: true } : {}), + ...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}), + ...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}), + settings: this.store.getSettings(), + getLaunchRuntime: (agent) => + this.resolveStartupAgentLaunchRuntime(repo, agent, launchInputs?.launchScope), + toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences) + }) + } +} diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index 0b1ed6ce8738..0e42f4a50a9d 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithListManagedWorktrees } from './managed-workspace-operations' +import { OrcaRuntimeWithBuildWorktreeStartup } from './startup/workspace-agent-startup' import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' import { navigationTargetsClients, @@ -7,20 +7,12 @@ import { } from '../../../shared/runtime-navigation' import { getRepoExecutionHostId } from '../../../shared/execution-host' import { phoneWakeReport } from '../../../shared/workspace-sleep' -import type { Repo } from '../../../shared/repo-types' -import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { RuntimeTerminalWait } from '../../../shared/runtime-terminal-contracts' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup } from '../runtime-worktree-agent-startup' -import { - buildWorktreeStartupForAgent, - buildWorktreeStartupForDraft, - resolveWorktreeStartupDraftAgent -} from '../runtime-worktree-agent-startup' -import type { AgentLaunchPreferences } from '../../../shared/agent-session-host-authority' import type { Worktree } from '../../../shared/worktree/types' import type { WorktreeLineageResolution } from '../runtime-worktree-lineage-resolution' import type { @@ -50,10 +42,7 @@ import { } from '../launched-agent-foreground' import { worktreeRowTarget } from '../../persistence/host-qualified-worktree-meta' -export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { - protected abstract toAgentSessionOptions( - preferences: AgentLaunchPreferences | undefined - ): Record | undefined +export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithBuildWorktreeStartup { protected abstract getWorktreeStartupReadinessHost(): WorktreeStartupReadinessHost protected abstract getWorktreeTerminalProvisioningHost( createdWorktree?: Worktree @@ -73,14 +62,17 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime /** unsupported-headless means no renderer holds wake records; clients must not claim agents resumed. */ sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { + const navigation = resolveRuntimeNavigationTarget({ ...opts, defaultTarget: 'host' }) + const targetsHost = navigationTargetsHost(navigation) + if (targetsHost) { + this.clientSessionTabSelections.noteHostNavigation() + } this.assertGraphReady() const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) if (!repo) { throw new Error('repo_not_found') } - const navigation = resolveRuntimeNavigationTarget({ ...opts, defaultTarget: 'host' }) - const targetsHost = navigationTargetsHost(navigation) const targetsClients = navigationTargetsClients(navigation) const target = this.store ? worktreeRowTarget(this.store, worktree) : undefined @@ -129,72 +121,6 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } - /** Use buildStartupForDraft's selection so launches can name the agent before creation. */ - async resolveStartupDraftAgent(repo: Repo, requestedAgent?: TuiAgent): Promise { - if (!this.store) { - return null - } - return resolveWorktreeStartupDraftAgent({ - repo, - settings: this.store.getSettings(), - ...(requestedAgent ? { requestedAgent } : {}) - }) - } - - protected async buildStartupForDraft( - repo: Repo, - draft: string, - requestedAgent?: TuiAgent, - launchSource?: string - ): ReturnType { - if (!this.store) { - return null - } - return buildWorktreeStartupForDraft({ - repo, - draft, - ...(requestedAgent ? { requestedAgent } : {}), - ...(launchSource ? { launchSource } : {}), - settings: this.store.getSettings(), - getLaunchRuntime: (agent) => this.resolveStartupAgentLaunchRuntime(repo, agent) - }) - } - - protected buildStartupForAgent( - repo: Repo, - agent: TuiAgent, - prompt: string | undefined, - launchPreferences?: AgentLaunchPreferences, - launchInputs?: { - agentArgs?: string | null - extraAgentArgs?: string - suppressStartupUpdatePrompt?: boolean - launchSource?: string - onPromptCarry?: (carried: boolean) => void - /** The existing workspace it starts in; absent is a new workspace of `repo`. */ - launchScope?: TerminalWorkspaceLaunchScope - } - ): ReturnType { - if (!this.store) { - throw new Error('runtime_unavailable') - } - return buildWorktreeStartupForAgent({ - repo, - agent, - ...(prompt !== undefined ? { prompt } : {}), - ...(launchPreferences ? { launchPreferences } : {}), - ...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}), - ...(launchInputs?.extraAgentArgs ? { extraAgentArgs: launchInputs.extraAgentArgs } : {}), - ...(launchInputs?.suppressStartupUpdatePrompt ? { suppressStartupUpdatePrompt: true } : {}), - ...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}), - ...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}), - settings: this.store.getSettings(), - getLaunchRuntime: (agent) => - this.resolveStartupAgentLaunchRuntime(repo, agent, launchInputs?.launchScope), - toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences) - }) - } - protected recordCreatedWorktreeLineage( worktree: Pick, lineageResolution: WorktreeLineageResolution diff --git a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts index a8f62248ed3e..ec3aad13643e 100644 --- a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts +++ b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts @@ -61,6 +61,9 @@ export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeW record: AgentSessionRecord ): Promise abstract isChatWorkerErrorEscalationEnabled(): boolean + abstract captureSessionSwitchStop( + ...args: Parameters + ): ReturnType abstract onStructuredSessionTabHidden( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index bfd51e7c85da..b2561a14c03f 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -284,6 +284,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr }, ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), conversationNaming: nameChat, + captureSessionSwitchStop: (sessionId) => this.captureSessionSwitchStop(sessionId), onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId), assertNoTerminalWriter: (record) => this.assertStructuredChatAcquireAdmissible(record), attentionDelivery: createStructuredAttentionMobileDelivery({ @@ -295,7 +296,8 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr reconcile: (state) => this.mobileNotifications.reconcileStructuredPromptAttention(state), now: () => Date.now() }), - // Re-read dispatch ownership each sweep; absence of a database cannot keep the worker alive. + // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps + // its agent running. No database answers no. hasOpenDispatch: (record) => structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) }) diff --git a/src/main/window/agent-launch-tab-publish-relay.ts b/src/main/window/agent-launch-tab-publish-relay.ts index cef2fd887cee..3986f043b4fb 100644 --- a/src/main/window/agent-launch-tab-publish-relay.ts +++ b/src/main/window/agent-launch-tab-publish-relay.ts @@ -43,7 +43,13 @@ export function requestAgentLaunchTabPublishFromRenderer( finish(new Error(reply.error)) return } - finish(null, { tabId: reply.tabId, created: reply.created, placement: reply.placement }) + finish(null, { + tabId: reply.tabId, + created: reply.created, + placement: reply.placement, + withdrawalRoot: reply.withdrawalRoot, + withdrawn: reply.withdrawn + }) } ipcMain.on('agentLaunch:tabPublishReply', onReply) webContents.send('ui:publishAgentLaunchTab', { ...request, requestId }) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index eea396f59869..5073330746e9 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -178,9 +178,14 @@ export function registerRuntimeWindowLifecycle( focusTerminal: (tabId, worktreeId, leafId) => send('ui:focusTerminal', { tabId, worktreeId, leafId }), revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target), - focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), - closeSessionTab: (tabId, worktreeId) => - requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), + focusEditorTab: (tabId, worktreeId, viewerSelection) => + send('ui:focusEditorTab', { + tabId, + worktreeId, + ...(viewerSelection !== undefined ? { viewerSelection } : {}) + }), + closeSessionTab: (tabId, worktreeId, switchProjection) => + requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId, switchProjection), moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) => send('ui:moveSessionTab', { worktreeId, ...move }), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?, position?) => diff --git a/src/main/window/session-tab-close-request-relay.ts b/src/main/window/session-tab-close-request-relay.ts index 834da75f24fb..409e87d99b2d 100644 --- a/src/main/window/session-tab-close-request-relay.ts +++ b/src/main/window/session-tab-close-request-relay.ts @@ -15,17 +15,18 @@ const SESSION_TAB_CLOSE_RESPONSE_GRACE_MS = 5_000 export async function requestSessionTabCloseFromRenderer( mainWindow: BrowserWindow, tabId: string, - worktreeId: string -): Promise { + worktreeId: string, + switchProjection?: SessionTabCloseRequest['switchProjection'] +): Promise { if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { throw new Error('renderer_unavailable') } const webContents = mainWindow.webContents const requestId = randomUUID() - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let settled = false const releaseListeners: (() => void)[] = [] - const finish = (error?: Error): void => { + const finish = (error?: Error, applied?: boolean): void => { if (settled) { return } @@ -37,7 +38,7 @@ export async function requestSessionTabCloseFromRenderer( if (error) { reject(error) } else { - resolve() + resolve(applied) } } const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable')) @@ -45,7 +46,7 @@ export async function requestSessionTabCloseFromRenderer( if (event.sender !== webContents || response.requestId !== requestId) { return } - finish(response.error ? new Error(response.error) : undefined) + finish(response.error ? new Error(response.error) : undefined, response.switchApplied) } const expiresAt = Date.now() + SESSION_TAB_CLOSE_CONFIRMATION_MS const timeout = setTimeout( @@ -62,7 +63,13 @@ export async function requestSessionTabCloseFromRenderer( onRendererUnavailable ) ) - const request: SessionTabCloseRequest = { requestId, tabId, worktreeId, expiresAt } + const request: SessionTabCloseRequest = { + requestId, + tabId, + worktreeId, + expiresAt, + ...(switchProjection ? { switchProjection } : {}) + } try { webContents.send('ui:sessionTabCloseRequest', request) } catch { diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index b3b7d6d7a656..8b10a3819a08 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { + TerminalSurfaceCloseIdentity, TerminalTabCloseRequest, TerminalTabCloseResponse } from '../../shared/terminal-tab-close' @@ -13,7 +14,11 @@ const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000 export async function requestTerminalTabCloseFromRenderer( mainWindow: BrowserWindow, tabId: string, - options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {} + options: { + localPtyTeardownOwnedExternally?: boolean + force?: boolean + originalSurface?: TerminalSurfaceCloseIdentity + } = {} ): Promise { if (mainWindow.isDestroyed()) { throw new Error('renderer_unavailable') diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index b0726d838801..216c5887fd4a 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -51,7 +51,7 @@ export type PtyApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -267,7 +267,8 @@ export type PtyApi = { callback: (data: { id: string code: number - preserveRendererBinding?: boolean + /** Retain presentation; only true carries reversible wake authority. */ + preserveRendererBinding?: boolean | 'closing' /** Which lifetime of `id` died; absent when the execution host predates the field. */ incarnationId?: string hostId?: string diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 765d81ee44f6..f50900ecbf1a 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -43,7 +43,7 @@ export const ptySessionControlApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 2630e78dfb7d..e6dad1e5df85 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -92,7 +92,7 @@ export const ptyStreamAndSerializationApi = { callback: (data: { id: string code: number - preserveRendererBinding?: boolean + preserveRendererBinding?: boolean | 'closing' incarnationId?: string hostId?: string ptySourceDisowned?: true @@ -104,7 +104,7 @@ export const ptyStreamAndSerializationApi = { data: { id: string code: number - preserveRendererBinding?: boolean + preserveRendererBinding?: boolean | 'closing' incarnationId?: string hostId?: string ptySourceDisowned?: true diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index a7564516b87a..0d0859aecb60 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,3 +1,7 @@ +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' import type { ManagedOrcadPreloadApi } from './managed-orcad-api' import type { WslHostsPreloadApi } from './wsl-hosts-api' import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' @@ -8,7 +12,6 @@ import type { RuntimeSyncWindowGraphResult, RuntimeTerminalDriverState } from '../../shared/runtime-types' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { SidebarInputsChangedEvent } from '../../shared/sidebar-list/sidebar-inputs-feed-contract' import type { @@ -141,6 +144,8 @@ export type RuntimeApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string }) => Promise> cancelSubscription: (args: { subscriptionId: string }) => Promise @@ -152,9 +157,12 @@ export type RuntimeApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string }, callbacks: { + onSent?: (sentAt?: number) => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError?: (error: { code: string; message: string }) => void diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index 7811b036d885..3f6eabc89189 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -1,3 +1,7 @@ +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' import { ipcRenderer } from 'electron' import { RUNTIME_HOST_STATUS_CHANNEL, @@ -5,7 +9,6 @@ import { } from '../../shared/runtime-host-status' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { RuntimeStatus } from '../../shared/runtime-types' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { PublicKnownRuntimeEnvironment, RuntimeEnvironmentRemoval @@ -118,6 +121,8 @@ export const runtimeEnvironmentsApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), cancelSubscription: (args: { subscriptionId: string }): Promise => @@ -130,9 +135,12 @@ export const runtimeEnvironmentsApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string }, callbacks: { + onSent?: (sentAt?: number) => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError?: (error: { code: string; message: string }) => void diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 62423bf50b60..59615da3bbc6 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -221,11 +221,16 @@ export const uiTerminalAndSessionTabsApi = { return () => ipcRenderer.removeListener('ui:focusTerminal', listener) }, onFocusEditorTab: ( - callback: (data: { tabId: string; worktreeId: string; userInitiated?: boolean }) => void + callback: (data: { + tabId: string + worktreeId: string + viewerSelection?: string + userInitiated?: boolean + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string; userInitiated?: boolean } + data: { tabId: string; worktreeId: string; viewerSelection?: string; userInitiated?: boolean } ) => callback(data) ipcRenderer.on('ui:focusEditorTab', listener) return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 1d880559b168..3e973c76dbcc 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -252,6 +252,7 @@ export type UiCommandEventApi = { callback: (data: { tabId: string worktreeId: string + viewerSelection?: string /** A notification click authorizes revealing the tab. */ userInitiated?: boolean }) => void diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts index d0d83110f16b..e756e8ac456b 100644 --- a/src/preload/runtime-environment-subscriptions.ts +++ b/src/preload/runtime-environment-subscriptions.ts @@ -1,4 +1,7 @@ -import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../shared/runtime-rpc-envelope' type RuntimeEnvironmentSubscribeArgs = { subscriptionId?: string @@ -7,10 +10,13 @@ type RuntimeEnvironmentSubscribeArgs = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + observeDispatch?: boolean + dispatchObservation?: RuntimeRpcDispatchObservation expectedEnvironmentRuntimeId?: string } type RuntimeEnvironmentSubscriptionCallbacks = { + onSent?: (sentAt?: number) => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError?: (error: { code: string; message: string }) => void @@ -23,10 +29,16 @@ export type RuntimeEnvironmentSubscriptionHandle = { } type RuntimeEnvironmentSubscriptionEvent = - | { subscriptionId: string; type: 'response'; response: RuntimeRpcResponse } + | { + subscriptionId: string + type: 'response' + response: RuntimeRpcResponse + sentAt?: number + } | { subscriptionId: string; type: 'binary'; bytes: Uint8Array } | { subscriptionId: string; type: 'error'; code: string; message: string } | { subscriptionId: string; type: 'close' } + | { subscriptionId: string; type: 'sent'; sentAt?: number } type RuntimeEnvironmentSubscriptionIpc = { invoke: (channel: string, args: unknown) => Promise @@ -90,7 +102,12 @@ function getOrCreateDispatcher( if (!subscriptionCallbacks) { return } - if (event.type === 'response') { + if (event.type === 'sent') { + subscriptionCallbacks.onSent?.(event.sentAt) + } else if (event.type === 'response') { + if (event.sentAt !== undefined) { + subscriptionCallbacks.onSent?.(event.sentAt) + } subscriptionCallbacks.onResponse(event.response) } else if (event.type === 'binary') { subscriptionCallbacks.onBinary?.(event.bytes) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts index 7943330f02f8..e034d886d150 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../shared/layout-owner-switch', () => ({ vi.mock('../runtime/server-layout-frames', () => ({ showServerLayoutFrames: hoisted.showServerLayoutFrames })) -vi.mock('../runtime/workspace-layout-frame-sync', () => ({ +vi.mock('../runtime/workspace-layout-frame-startup', () => ({ showStartupLayoutFrames: hoisted.showStartupLayoutFrames })) vi.mock('../runtime/sleeping-records-to-layout-owner', () => ({ diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 7c70f77e2d8f..5a9699cf0214 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -1,6 +1,6 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-sync' +import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-startup' import { showServerLayoutFrames } from '../runtime/server-layout-frames' import { followSleepingRecordsInLayoutOwner } from '../runtime/sleeping-records-to-layout-owner' diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx index 136c0b379756..df734927e906 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx @@ -1,3 +1,4 @@ +import { noteViewerGroupNavigation } from '@/lib/viewer-selection-request' import { memo, useCallback, useEffect, useMemo } from 'react' import { registerBrowserOverlaySlotViewport } from '../host-guest/browser-page-viewport' import { useShallow } from 'zustand/react/shallow' @@ -147,7 +148,10 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ // Why: stable identity so BrowserOverlaySlot's memo holds; groupId is passed at call time so one callback serves every slot. const focusOwningGroup = useCallback( - (groupId: string) => focusGroup(worktreeId, groupId), + (groupId: string) => { + noteViewerGroupNavigation(useAppStore.getState(), worktreeId, groupId) + focusGroup(worktreeId, groupId) + }, [focusGroup, worktreeId] ) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts index 5bd062be10c0..9c553da565e4 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { revealTerminalPane } from '@/lib/reveal-terminal-pane' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' @@ -9,6 +10,7 @@ import { useAppStore } from '@/store' import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot' export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean { + noteViewerNavigation() const state = useAppStore.getState() const tab = state.unifiedTabsByWorktree[args.worktreeId]?.find( (candidate) => diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts index d099f521b1cd..602dc974bd9a 100644 --- a/src/renderer/src/components/editor/check-annotation-open.ts +++ b/src/renderer/src/components/editor/check-annotation-open.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { detectLanguage } from '@/lib/language-detect' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' @@ -29,6 +30,7 @@ export function openAnnotationLocation(params: { const { absolutePath, relativePath } = resolvedPath const language = detectLanguage(relativePath) + noteViewerNavigation() // Use shared activation so annotation jumps join sidebar, palette and terminal-link navigation history. activateAndRevealWorktree(worktreeId, { providesInitialSurface: true, executionHostId }) diff --git a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx index 410d9d3ac665..fd8e7bb9822a 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx @@ -1,3 +1,4 @@ +import { noteViewerGroupNavigation } from '@/lib/viewer-selection-request' import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' @@ -70,7 +71,10 @@ const EmulatorPaneOverlayLayer = memo(function EmulatorPaneOverlayLayer({ ) const focusGroup = useAppStore((state) => state.focusGroup) const focusOwningGroup = useCallback( - (groupId: string) => focusGroup(worktreeId, groupId), + (groupId: string) => { + noteViewerGroupNavigation(useAppStore.getState(), worktreeId, groupId) + focusGroup(worktreeId, groupId) + }, [focusGroup, worktreeId] ) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 448390791129..6c92f28dfe4c 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' @@ -43,6 +44,7 @@ export function useFloatingTerminalCreateActions({ }: FloatingTerminalCreateActionsInput) { const activateFloatingItem = useCallback( (visibleId: string) => { + noteViewerNavigation() const item = resolveGroupTabFromVisibleId(groupTabs, visibleId) if (!item) { return @@ -67,6 +69,7 @@ export function useFloatingTerminalCreateActions({ const createFloatingTerminalTab = useCallback( (shellOverride?: string) => { + noteViewerNavigation() const tabId = createBrowserUuid() createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride, { id: tabId }) focusTerminalTabSurface(tabId) @@ -75,6 +78,7 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + noteViewerNavigation() if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { return } @@ -91,6 +95,7 @@ export function useFloatingTerminalCreateActions({ }, [activeGroup, browserDefaultUrl, createBrowserTab]) const createFloatingMarkdownTab = useCallback(() => { + noteViewerNavigation() if (!markdownCwd) { return } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index c84e62279dba..d192758c451d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -1,3 +1,4 @@ +import { useSwitchPresentationRows } from '@/lib/conversation-switch-presentation' import { copyChatMathSelection } from '../sidebar/chat-markdown-math-selection' import { nativeChatTranscriptOuterClass, @@ -132,6 +133,7 @@ export function NativeChatMessageList({ [journalItems] ) const scrollRef = useRef(null) + useSwitchPresentationRows(scrollRef) const contentRef = useRef(null) const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()) const disclosures = useNativeChatDisclosures() diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 407d1f109ad1..7845097bff51 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -1,3 +1,4 @@ +import { useSwitchFailureSuppressed } from '@/lib/conversation-switch-presentation' import { agentSessionGoalControlWords } from './agent-session-goal-control-words' import { AlertCircle, AlertTriangle, Info } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -72,11 +73,15 @@ export function NativeChatNoticeRow({ agentName?: string onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean -}): React.JSX.Element { +}): React.JSX.Element | null { + const switchSuppressed = useSwitchFailureSuppressed(messageId) useTranslation() const orcaStopView = useNativeChatOrcaStopView() const claudeSignIn = useNativeChatClaudeSignInView() const look = nativeChatNoticeLook(block) + if (switchSuppressed) { + return null + } if (look.kind === 'divider') { const label = look.label === 'contextCleared' diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx index 2087eaf617fa..08b2eae7c67d 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx @@ -1,12 +1,31 @@ +import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + act, + cleanup, + fireEvent, + render as renderWithConfirmation, + screen, + waitFor +} from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import { useAppStore } from '@/store' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' import * as historyRequest from '../right-sidebar/ai-vault-panel-session-list-request' +function render(ui: React.ReactNode) { + return renderWithConfirmation(ui, { wrapper: ConfirmationDialogProvider }) +} + +vi.mock('@/runtime/structured-agent-session-client', async (original) => ({ + ...(await original()), + supportsInPlaceSwitch: async () => false +})) + const mocks = vi.hoisted(() => ({ launch: vi.fn(), resume: vi.fn(), close: vi.fn() })) vi.mock('@/store', async () => { const { createTestStore } = await import('@/store/slices/store-test-helpers') @@ -74,7 +93,79 @@ it('puts Hand Off first like the terminal header, only on the focused pane, and expect(screen.queryByRole('button', { name: 'Hand Off to Another Agent' })).toBeNull() }) -it('offers the terminal header action order and targets Resume and Close at the displayed chat', () => { +it('keeps the switch hit target stable while its inactive pane gains focus during one press', () => { + const source = useAppStore.getState().createUnifiedTab('folder:workspace', 'agent-session', { + agentSessionAgent: 'claude', + executionHostId: 'runtime:remote-host' + }) + const otherGroup = useAppStore + .getState() + .createEmptySplitGroup(source.worktreeId, source.groupId, 'right') + if (otherGroup === null) { + throw new Error('Expected the sibling split group to be created') + } + const switchToCli = vi.fn() + const handOff = { open: vi.fn(), disabled: false } + function FocusableHeader() { + const focused = useAppStore( + (state) => state.activeGroupIdByWorktree[source.worktreeId] === source.groupId + ) + return ( + + + useAppStore.getState().focusGroup(source.worktreeId, groupId) + } + > + Switch to CLI} + /> + + + ) + } + const view = render() + const switchButton = screen.getByRole('button', { name: 'Switch to CLI' }) + const row = switchButton.closest('.pane-title-actions')! + const members = Array.from(row.children) + const handOffButton = row.querySelector( + 'button[aria-label="Hand Off to Another Agent"]' + )! + expect(useAppStore.getState().activeGroupIdByWorktree[source.worktreeId]).toBe(otherGroup) + expect(handOffButton).not.toBeNull() + expect(getComputedStyle(handOffButton).visibility).toBe('hidden') + expect(screen.queryByRole('button', { name: 'Hand Off to Another Agent' })).toBeNull() + expect(switchButton.hasAttribute('disabled')).toBe(false) + + fireEvent.pointerDown(switchButton) + expect(useAppStore.getState().activeGroupIdByWorktree[source.worktreeId]).toBe(source.groupId) + expect(Array.from(row.children)).toEqual(members) + expect(screen.getByRole('button', { name: 'Switch to CLI' })).toBe(switchButton) + expect(screen.getByRole('button', { name: 'Hand Off to Another Agent' })).toBe(handOffButton) + expect(getComputedStyle(handOffButton).visibility).toBe('visible') + fireEvent.focus(switchButton) + fireEvent.pointerUp(switchButton) + fireEvent.click(switchButton) + expect(switchToCli).toHaveBeenCalledOnce() + expect(handOff.open).not.toHaveBeenCalled() + + act(() => useAppStore.getState().focusGroup(source.worktreeId, otherGroup)) + expect(Array.from(row.children)).toEqual(members) + expect(getComputedStyle(handOffButton).visibility).toBe('hidden') + expect(screen.queryByRole('button', { name: 'Hand Off to Another Agent' })).toBeNull() + view.unmount() +}) + +it('offers the terminal header action order and targets Resume and Close at the displayed chat', async () => { const source = renderHeader() expect(screen.getAllByRole('button').map((button) => button.getAttribute('aria-label'))).toEqual([ 'Continue with Terminal Agent', @@ -88,15 +179,19 @@ it('offers the terminal header action order and targets Resume and Close at the expect(fullRequest).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Continue with Terminal Agent' })) expect(fullRequest).toHaveBeenCalledTimes(1) - expect(mocks.resume).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - subject: expect.objectContaining({ - sessionId: source.entityId, - request: { scopePaths: ['/repo'], executionHostScope: 'local', sessionLimit: 250 } - }), - tab: { id: source.id, worktreeId: source.worktreeId }, - agent: 'codex' - }) + await waitFor(() => + expect(mocks.resume).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + subject: expect.objectContaining({ + sessionId: source.entityId, + request: { scopePaths: ['/repo'], executionHostScope: 'local', sessionLimit: 250 } + }), + tab: { id: source.id, worktreeId: source.worktreeId }, + agent: 'codex', + queuedMessage: + 'A copy of this Codex conversation opens in a new terminal. The original chat stays open.' + }) + ) ) fireEvent.click(screen.getByRole('button', { name: 'Close tab' })) expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 35a386b82140..faf5fe6018fa 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -24,6 +24,7 @@ export function NativeChatPaneHeader({ nativeChat, focused, colorScheme, + topRightAction, handOff }: { tabId: string @@ -32,6 +33,7 @@ export function NativeChatPaneHeader({ nativeChat: NativeChatTabSession focused: boolean colorScheme: React.CSSProperties['colorScheme'] + topRightAction?: React.ReactNode handOff?: NativeChatHeaderHandOff }): React.JSX.Element { const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId) @@ -54,7 +56,8 @@ export function NativeChatPaneHeader({ style={HEADER_STYLE} >
- {handOff && focused ? ( + {topRightAction} + {handOff ? ( + + + {readiness.explanation ?? } + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx index 3f6d65314403..d50725fea8e2 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx @@ -45,7 +45,12 @@ export function NativeChatTranscriptItems({ return (
-
+
state.focusGroup) const focusOwningGroup = useCallback( - (groupId: string) => focusGroup(worktreeId, groupId), + (groupId: string) => { + noteViewerGroupNavigation(useAppStore.getState(), worktreeId, groupId) + focusGroup(worktreeId, groupId) + }, [focusGroup, worktreeId] ) const groupActiveTabById = useMemo( diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx index f15219ee60c9..ef7811c0d2c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx @@ -1,4 +1,6 @@ // @vitest-environment happy-dom + +import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest' @@ -159,9 +161,11 @@ async function mount(): Promise> { let view: ReturnType await act(async () => { view = render( - - - + + + + + ) }) return view! diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx index a22c5f8d57f6..46388fb222c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -1,6 +1,7 @@ +import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' // @vitest-environment happy-dom import { Profiler } from 'react' -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' @@ -129,9 +130,11 @@ function seedChats(size: number) { } function overlay(active: boolean) { return ( - - - + + + + + ) } function chat(container: HTMLElement, id: string) { @@ -202,21 +205,23 @@ function eligibility( tabId = 'tab' ) { return ( - - work.commits++}> - {[ - { key: 'folder', id: folder }, - { key: 'git', id: git } - ].map(({ key, id }) => ( - - ))} - - + + + work.commits++}> + {[ + { key: 'folder', id: folder }, + { key: 'git', id: git } + ].map(({ key, id }) => ( + + ))} + + + ) } @@ -368,7 +373,7 @@ it('keeps the real Continue pending operation disabled across header hide/reveal expect(button().hasAttribute('disabled')).toBe(true) expect(button().getAttribute('aria-busy')).toBe('true') fireEvent.click(button()) - expect(mocks.prepare).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mocks.prepare).toHaveBeenCalledTimes(1)) await act(async () => preparation.resolve({ copied: false, diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index 42db3f3df78c..9f0dcd512ffe 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -3,6 +3,7 @@ import type { NativeChatReadSessionResult } from '../../../../preload/api-types' import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types' +import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../../shared/agent-turn-outcome' import { translate } from '@/i18n/i18n' export function runtimeNativeChatReadError(): string { @@ -33,9 +34,11 @@ export function parseRuntimeNativeChatTurnLifecycle( ) { return undefined } + const outcome = AGENT_JOURNAL_TURN_OUTCOMES.find((known) => known === record.outcome) return { state: record.state, turnId: record.turnId.trim(), + ...(outcome ? { outcome } : {}), // Missing timestamps are valid; normalize them without dropping the lifecycle record. timestamp: record.timestamp ?? null } diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index fa37984706f7..3fd4c66567c9 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -89,7 +89,12 @@ describe('getNativeChatSessionTransport — selection', () => { it('validates lifecycle metadata on runtime read responses', async () => { markRuntimeEnvironmentCompatible(ENV) - const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const + const lifecycle = { + state: 'completed', + outcome: 'failure', + turnId: 'turn-1', + timestamp: 42 + } as const runtimeEnvironmentsCall .mockResolvedValueOnce(okEnvelope({ messages: [message('valid')], lifecycle })) .mockResolvedValueOnce( @@ -224,7 +229,12 @@ describe('runtime subscribe', () => { const { deliver } = stubSubscribe() const onFrame = vi.fn() const transport = getNativeChatSessionTransport(ENV) - const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const + const lifecycle = { + state: 'completed', + outcome: 'failure', + turnId: 'turn-1', + timestamp: 42 + } as const transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) await Promise.resolve() diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index cfac26627b16..ce12d4f3bf4f 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -1,3 +1,10 @@ +import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' +import { + useNativeChatClaudeSignIn, + isClaudeSignInFailureKind, + nativeChatClaudeSignInLabel, + type NativeChatClaudeSignIn +} from './native-chat-claude-sign-in' import { nativeChatAntigravitySignInContent } from './native-chat-antigravity-settings' import { translate } from '@/i18n/i18n' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' @@ -5,11 +12,6 @@ import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session- import type { StructuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' import { joinSentences } from '../../../../shared/sentence-joining' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - isClaudeSignInFailureKind, - nativeChatClaudeSignInLabel, - type NativeChatClaudeSignIn -} from './native-chat-claude-sign-in' import type { NativeChatComposerNotice, NativeChatComposerNoticeContent @@ -120,3 +122,18 @@ export function structuredSessionNotices({ ...(composerError ? [{ key: 'composer-error', kind: 'error' as const, ...composerError }] : []) ] } + +export function useStructuredSessionNotices(args: { + availability: Parameters[0] + signIn: Parameters[0] + notices: Omit[0], 'availability' | 'claudeSignIn'> + hostNotice: NativeChatComposerNotice | null +}) { + const availability = useNativeChatAvailabilityNotice(args.availability) + const claudeSignIn = useNativeChatClaudeSignIn(args.signIn) + const notices = structuredSessionNotices({ ...args.notices, claudeSignIn, availability }) + if (args.hostNotice) { + notices.push(args.hostNotice) + } + return { claudeSignIn, notices } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 98a8b5bbc8ae..167687481aa2 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -1,3 +1,4 @@ +import { noteSwitchPresentationUse } from '@/lib/conversation-switch-projection' // Allow one unanswered send per chat in memory; host rows/cards own admitted messages. Never retry a send or retain it past its deadline. import type { @@ -97,6 +98,7 @@ function dispatch( export function sendStructuredAgentSessionMessage( input: StructuredAgentSessionSendInput & { target: RuntimeClientTarget } ): StructuredAgentSessionSent | null { + noteSwitchPresentationUse(input.sessionId) const entry = takeStructuredAgentSessionSendSlot(input) return entry ? dispatch(entry, input.target) : null } @@ -126,6 +128,7 @@ export type StructuredAgentSessionCreateSend = StructuredAgentSessionSent & { export function reserveStructuredAgentSessionSend( input: StructuredAgentSessionSendInput ): StructuredAgentSessionReservedSend | null { + noteSwitchPresentationUse(input.sessionId) const entry = takeStructuredAgentSessionSendSlot(input) if (!entry) { return null diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index 01a7df7fd519..412f6d59f645 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -497,9 +497,9 @@ describe('useNativeChatContextMenu', () => { const labels = items.list.map((candidate) => childrenText(candidate.children)) - expect(labels.some((label) => label.startsWith('Continue with Terminal Agent'))).toBe(true) + expect(labels.some((label) => label.startsWith('Show as terminal'))).toBe(true) const item = items.list.find((candidate) => - childrenText(candidate.children).startsWith('Continue with Terminal Agent') + childrenText(candidate.children).startsWith('Show as terminal') ) expect(item).toBeDefined() item?.onSelect?.() @@ -510,9 +510,7 @@ describe('useNativeChatContextMenu', () => { renderToStaticMarkup() expect( - items.list.some( - (candidate) => childrenText(candidate.children) === 'Continue with Terminal Agent' - ) + items.list.some((candidate) => childrenText(candidate.children) === 'Show as terminal') ).toBe(false) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 686b4fdeaba5..889b27e93a0e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -306,8 +306,8 @@ export function useNativeChatContextMenu({ {translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Continue with Terminal Agent' + 'components.tab.bar.SortableTabContextMenu.showAsTerminal', + 'Show as terminal' )} {shortcutLabel ? ( {shortcutLabel} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 106c3bcd8f54..79ac9c3a98e7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { usePluginCommands } from '@/store/plugin-panels' import { useEffect, useLayoutEffect, useRef } from 'react' import { matchesRecentTabSwitcherChord } from '../../../../shared/window-shortcut-policy' @@ -132,6 +133,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive ) { return } + noteViewerNavigation() state.toggleTabViewMode(tab.id) e.preventDefault() e.stopImmediatePropagation() diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-delivery.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-delivery.ts new file mode 100644 index 000000000000..aceeb6c08edc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-delivery.ts @@ -0,0 +1,32 @@ +import { isClaudeSignInFailureKind } from './native-chat-claude-sign-in' +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' + +export function useStructuredNativeChatDelivery( + args: Omit[0], 'startFailures'> & { + agent: string + hasReturnedMessages: boolean + } +) { + const needsFailureFacts = + args.hasReturnedMessages || + args.submissions.some( + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) + ) + const startFailures = useStructuredAgentSessionStartFailureFacts( + args.journalItems, + needsFailureFacts || args.agent === 'claude' + ) + const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ + pending: args.pending, + submissions: args.submissions, + journalItems: args.journalItems, + startFailures, + agentName: args.agentName + }) + const signInFailureRows = startFailures.filter((fact) => + isClaudeSignInFailureKind(fact.kind) + ).length + return { startFailures, deliveryNotices, signInFailureRows } +} diff --git a/src/renderer/src/components/quick-open-file-navigation.ts b/src/renderer/src/components/quick-open-file-navigation.ts index 87ab1782bf45..84436cc7a119 100644 --- a/src/renderer/src/components/quick-open-file-navigation.ts +++ b/src/renderer/src/components/quick-open-file-navigation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { prepareQuickOpenFiles, rankQuickOpenFiles } from '../../../shared/quick-open-path-search' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { detectLanguage } from '@/lib/language-detect' @@ -29,6 +30,7 @@ export async function openQuickOpenFile( rawQuery?: string, assertInteractionCurrent: () => void = () => {} ): Promise { + noteViewerNavigation() const guard = captureFileExplorerOperationGuard( worktreeId, getFileExplorerOperationOwner(worktreeId) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx index 4ba56f3072e2..78eeb084cb5a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -26,7 +26,8 @@ const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boo vi.mock('@/store', () => ({ useAppStore: Object.assign((select: (state: typeof mockState) => unknown) => select(mockState), { - getState: () => ({ ...mockState, updateSettingsOrThrow, markFeatureTipsSeen: vi.fn() }) + getState: () => ({ ...mockState, updateSettingsOrThrow, markFeatureTipsSeen: vi.fn() }), + subscribe: vi.fn(() => vi.fn()) }) })) vi.mock('@/store/selectors', () => ({ diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index c6e55d330701..0bcabbd4ab84 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -1,3 +1,4 @@ +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' import { Copy, FileJson, @@ -13,7 +14,10 @@ import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-m import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { AiVaultSessionSurfaceSwitchMenuItems } from './AiVaultSessionSurfaceSwitchMenuItems' +import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import { isAiVaultFolderWorkspaceId } from './ai-vault-session-worktree-affordances' +import { useAppStore } from '@/store' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' export function SessionActionMenuItems({ menuKind = 'dropdown', @@ -35,9 +39,14 @@ export function SessionActionMenuItems({ onRevealLog, onOpenCwd, deleteBlockedReason, - onDelete + onDelete, + inPlaceAgent, + switchLoading, + originalPaneTarget, + session }: { menuKind?: 'dropdown' | 'context' + originalPaneTarget?: AiVaultOriginalPaneTarget | null resumeDisabled: boolean resumeLabel: string resumeHidden?: boolean @@ -60,7 +69,12 @@ export function SessionActionMenuItems({ // Null when Delete is offered; otherwise the tooltip explaining why it isn't. deleteBlockedReason: string | null onDelete?: () => void + session?: AiVaultSession + switchLoading?: boolean + inPlaceAgent?: AiVaultAgent }) { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + const showSurfaceActions = enabled || (inPlaceAgent === undefined && !switchLoading) const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) @@ -69,8 +83,7 @@ export function SessionActionMenuItems({ onJumpToOriginalPane || showJumpToWorktree || !resumeHidden || - onResumeInNewChat || - onResumeInNewCli || + (showSurfaceActions && (onResumeInNewChat || onResumeInNewCli)) || onCopyResume ) const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') @@ -118,13 +131,19 @@ export function SessionActionMenuItems({ {resumeLabel} ) : null} - + {showSurfaceActions ? ( + + ) : null} {onCopyResume ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 362e8a77cc6c..91694941ff21 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,16 +1,8 @@ +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import type React from 'react' -import { - FileJson, - Folder, - FolderGit2, - MessageSquare, - MessageSquarePlus, - MessagesSquare, - Play, - SquareTerminal -} from 'lucide-react' +import { FileJson, Folder, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -21,7 +13,7 @@ import { } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { FirstPromptCard } from './ai-vault-first-prompt-card' -import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' +import { AiVaultSessionDetailsSwitchButtons } from './AiVaultSessionDetailsSwitchButtons' import { sessionDetailConversationTurns, sessionPromptPreview } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' @@ -39,6 +31,7 @@ import { export function SessionInlineDetails({ id, session, + originalPaneTarget, worktreeInfo, vaultScope, resumeActions, @@ -53,6 +46,7 @@ export function SessionInlineDetails({ }: { id: string session: AiVaultSession + originalPaneTarget?: AiVaultOriginalPaneTarget | null worktreeInfo: AiVaultSessionWorktreeInfo | null vaultScope: AiVaultScope resumeActions: { @@ -140,51 +134,12 @@ export function SessionInlineDetails({ )} ) : null} - {onResumeInNewChat ? ( - - ) : null} - {onResumeInNewCli ? ( - - - - - - - - - ) : null} + {onContinueInNewSession ? ( + ) : null} + {onResumeInNewCli ? ( + + + + + + {inPlaceAgent ? ( + + ) : ( + + )} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index 09b650b533a0..abf7c6a52c5f 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -10,9 +10,14 @@ import type { Tab } from '../../../../shared/tab-types' import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from '../../../../shared/agent-session/agent-session-workspace-sleep' import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), supports: vi.fn() })) +const mocks = vi.hoisted(() => ({ + subscribe: vi.fn(), + supports: vi.fn(), + supportsInPlaceSwitch: vi.fn(async () => false) +})) vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionStatus: mocks.subscribe + subscribeStructuredAgentSessionStatus: mocks.subscribe, + supportsInPlaceSwitch: mocks.supportsInPlaceSwitch })) vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index c46de35484e2..949204920602 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,3 +1,5 @@ +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' +import { useAiVaultInPlaceAgent, useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { useCallback } from 'react' @@ -32,6 +34,7 @@ import { useAiVaultSessionDisplayTitle } from './use-ai-vault-session-display-ti export function VaultSessionRow({ session, + originalPaneTarget, liveState, resumeStartup, realHomeResumeStartup, @@ -65,6 +68,7 @@ export function VaultSessionRow({ onPick }: { session: AiVaultSession + originalPaneTarget?: AiVaultOriginalPaneTarget | null liveState: AgentDotState | null resumeStartup: AiVaultResumeStartup realHomeResumeStartup: AiVaultResumeStartup @@ -105,6 +109,9 @@ export function VaultSessionRow({ const updatedAt = session.updatedAt ?? session.modifiedAt const title = useAiVaultSessionDisplayTitle(session) const detailsId = getSessionDetailsId(session.id) + const inPlaceAgent = useAiVaultInPlaceAgent(session) + const switchLoading = + useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined const latestTurn = latestSessionConversationTurn(session) const deleteHost = useAiVaultSessionDeleteHost(session) const deleteBlockedReason = onRequestDelete @@ -192,6 +199,7 @@ export function VaultSessionRow({ {title}
{detailsExpanded ? ( void onResumeInNewCli?: (event: Event) => void cliPending?: boolean + /** The agent whose conversation switches in place on its host; unset keeps the new-tab entries. */ + switchLoading?: boolean + inPlaceAgent?: AiVaultAgent }): React.JSX.Element { + const chat = useConversationSwitchReadiness( + session ? historyConversationSwitch(session, 'chat', undefined, originalPaneTarget) : null + ) + const cli = useConversationSwitchReadiness( + session ? historyConversationSwitch(session, 'terminal', undefined, originalPaneTarget) : null + ) + const heldChat = session && inPlaceAgent ? chat : null + const heldCli = session?.structuredSession && inPlaceAgent ? cli : null + const inPlace = inPlaceAgent !== undefined const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem return ( <> {onResumeInNewChat ? ( - {action === 'resume' + {action === 'resume' && !inPlace ? translate( 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat', 'Resume in New Chat' ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Continue in Chat UI' - )} + : translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat')} ) : null} {onResumeInNewCli ? ( - + {cliPending ? ( ) : ( )} - {action === 'resume' - ? translate( + {inPlace + ? translate('components.tab.bar.switch.toCli', 'Switch to CLI') + : translate( 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', 'Resume in New Terminal' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Continue with Terminal Agent' )} - + {inPlaceAgent ? ( + + ) : ( + + )} ) : null} diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx index 421b18146fa1..b30c0aec4469 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment happy-dom +import { getDefaultSettings } from '../../../../shared/constants' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -7,11 +8,12 @@ import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { AgentDotState } from '@/components/AgentStateDot' -import { useAppStore } from '@/store' import { createEditorStore } from '@/store/slices/editor-slice-test-harness' import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { aiVaultSearchHitToSession } from './ai-vault-search-session' import { AiVaultVirtualRow } from './AiVaultVirtualRow' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { useAppStore } from '@/store' const cliSession: AiVaultSession = { id: 'local:codex:session-1:/tmp/session-1.jsonl', @@ -38,10 +40,84 @@ const cliSession: AiVaultSession = { afterEach(() => { cleanup() + setLocalRuntimeCapabilitiesForTests(null) vi.restoreAllMocks() Reflect.deleteProperty(window, 'api') }) +function sessionRowFixture( + session: AiVaultSession, + blocked = false, + searchHit?: AiVaultSearchHit, + searchHitHostLabels?: ReadonlyMap, + index = 0, + resumeInChat = false, + liveState: AgentDotState | null = null, + logDetails?: { onOpenLog: (session: AiVaultSession) => Promise } +) { + const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand })) + const onCopyResume = vi.fn() + const onResume = vi.fn() + const onResumeInNewCli = vi.fn() + const getOriginalPaneTarget = vi.fn(() => null) + const element = ( + false} + getSessionLiveState={() => liveState} + getWorktreeInfo={() => null} + getSessionResumeState={() => ({ + blocked, + worktreeId: blocked ? null : 'worktree-1', + usesSessionWorktree: false + })} + getSessionResumeActions={() => ({ + worktree: { worktreeId: null, disabled: true }, + newTab: { worktreeId: 'worktree-1', disabled: blocked } + })} + getSessionResumeInChat={() => + resumeInChat + ? { available: true, workspaceId: 'worktree-1' } + : { available: false, reason: 'already-structured' } + } + onToggleGroup={vi.fn()} + onToggleSessionDetails={vi.fn()} + onJumpToOriginalPane={vi.fn()} + onJumpToWorktree={vi.fn()} + onResume={onResume} + onContinueInNewSession={vi.fn()} + onResumeInNewChat={vi.fn()} + onResumeInNewCli={onResumeInNewCli} + onCopyResume={onCopyResume} + onCopyId={vi.fn()} + onCopyPath={vi.fn()} + onOpenLog={logDetails?.onOpenLog ?? vi.fn()} + onRevealLog={vi.fn()} + onOpenCwd={vi.fn()} + onRequestDelete={vi.fn()} + /> + ) + return { + element, + getOriginalPaneTarget, + buildResumeStartup, + onCopyResume, + onResume, + onResumeInNewCli + } +} + function renderSession( session: AiVaultSession, blocked = false, @@ -53,57 +129,18 @@ function renderSession( if (logDetails) { Object.defineProperty(window, 'api', { configurable: true, value: { aiVault: {} } }) } - const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand })) - const onCopyResume = vi.fn() - const onResume = vi.fn() - const onResumeInNewCli = vi.fn() - render( - - null} - isStructuredSessionOpen={() => false} - getSessionLiveState={() => liveState} - getWorktreeInfo={() => null} - getSessionResumeState={() => ({ - blocked, - worktreeId: blocked ? null : 'worktree-1', - usesSessionWorktree: false - })} - getSessionResumeActions={() => ({ - worktree: { worktreeId: null, disabled: true }, - newTab: { worktreeId: 'worktree-1', disabled: blocked } - })} - getSessionResumeInChat={() => ({ available: false, reason: 'already-structured' })} - onToggleGroup={vi.fn()} - onToggleSessionDetails={vi.fn()} - onJumpToOriginalPane={vi.fn()} - onJumpToWorktree={vi.fn()} - onResume={onResume} - onContinueInNewSession={vi.fn()} - onResumeInNewChat={vi.fn()} - onResumeInNewCli={onResumeInNewCli} - onCopyResume={onCopyResume} - onCopyId={vi.fn()} - onCopyPath={vi.fn()} - onOpenLog={logDetails?.onOpenLog ?? vi.fn()} - onRevealLog={vi.fn()} - onOpenCwd={vi.fn()} - onRequestDelete={vi.fn()} - /> - + const fixture = sessionRowFixture( + session, + blocked, + searchHit, + searchHitHostLabels, + 0, + false, + liveState, + logDetails ) - return { buildResumeStartup, onCopyResume, onResume, onResumeInNewCli } + render({fixture.element}) + return fixture } describe('AiVaultVirtualRow conversation logs', () => { @@ -281,22 +318,30 @@ describe('AiVaultVirtualRow resume command actions', () => { }) }) -describe('AiVaultVirtualRow Resume in New Terminal', () => { +describe('AiVaultVirtualRow CLI surface actions', () => { const owner = { sessionId: 'chat-1', workspaceId: 'worktree-1' } it.each(['claude', 'codex'] as const)( - 'offers the fork on a chat-owned %s row from both menus', + 'offers the ordinary CLI switch on an older host for a chat-owned %s row from both menus', async (agent) => { + useAppStore.setState((state) => ({ + settings: { + ...getDefaultSettings('/home/test'), + ...state.settings, + experimentalNativeChat: true + } + })) + setLocalRuntimeCapabilitiesForTests([]) const session: AiVaultSession = { ...cliSession, agent, structuredSession: owner } const { onResumeInNewCli, onResume } = renderSession(session) const user = userEvent.setup() await user.click(screen.getByTestId('ai-vault-session-more-actions')) - await user.click(await screen.findByRole('menuitem', { name: 'Resume in New Terminal' })) + await user.click(await screen.findByRole('menuitem', { name: 'Switch to CLI' })) expect(onResumeInNewCli).toHaveBeenCalledExactlyOnceWith(session, 'worktree-1') fireEvent.contextMenu(screen.getByText('CLI session')) - await user.click(await screen.findByRole('menuitem', { name: 'Resume in New Terminal' })) + await user.click(await screen.findByRole('menuitem', { name: 'Switch to CLI' })) expect(onResumeInNewCli).toHaveBeenCalledTimes(2) expect(onResume).not.toHaveBeenCalled() } diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx index 07f2071a1423..a8984ac856d9 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx @@ -1,3 +1,4 @@ +import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import type { AgentDotState } from '@/components/AgentStateDot' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -92,7 +93,11 @@ export function AiVaultVirtualRow({ onJumpToWorktree: (worktreeId: string) => void onResume: (session: AiVaultSession, worktreeId: string) => void onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void - onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void + onResumeInNewChat: ( + session: AiVaultSession, + worktreeId: string, + pane?: AiVaultOriginalPaneTarget | null + ) => void onResumeInNewCli: (session: AiVaultSession, worktreeId: string) => void onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void onCopyId: (session: AiVaultSession) => void @@ -109,6 +114,10 @@ export function AiVaultVirtualRow({ /** Set in `/resume`'s picker; see VaultSessionRow's `onPick`. */ onPickSession?: (session: AiVaultSession) => void }): React.JSX.Element | null { + useInPlaceSwitchCapability( + row?.type === 'session' ? row.session.executionHostId : undefined, + row?.type === 'session' ? row.session.agent : undefined + ) if (!row) { return null } @@ -204,6 +213,7 @@ export function AiVaultVirtualRow({ /> ) : ( onResumeInNewChat(row.session, resumeInNewChatWorkspaceId) + ? () => onResumeInNewChat(row.session, resumeInNewChatWorkspaceId, originalPaneTarget) : undefined } onResumeInNewCli={ diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 2f958806a1cc..2a7ba38146af 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -1,3 +1,5 @@ +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' +import { useAiVaultInPlaceAgent, useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import { useEffect, useState } from 'react' import type React from 'react' import { @@ -39,6 +41,7 @@ const HOVER_ACTION_GROUP_CLASS = `${HOVER_ACTION_GROUP_BASE} ${HOVER_ACTION_GROU export function SessionRowTrailingActions({ session, + originalPaneTarget, detailsExpanded, detailsId, detailsTooltip, @@ -65,6 +68,7 @@ export function SessionRowTrailingActions({ actionsHidden = false }: { session: AiVaultSession + originalPaneTarget?: AiVaultOriginalPaneTarget | null detailsExpanded: boolean detailsId: string detailsTooltip: string @@ -92,6 +96,9 @@ export function SessionRowTrailingActions({ /** Only the details toggle shows. */ actionsHidden?: boolean }) { + const inPlaceAgent = useAiVaultInPlaceAgent(session) + const switchLoading = + useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) const [menuOpen, setMenuOpen] = useState(false) @@ -280,6 +287,8 @@ export function SessionRowTrailingActions({ ({ + probe: vi.fn<() => Promise>(), + owner: vi.fn<() => string | null>(() => 'local'), + newChat: vi.fn(), + fork: vi.fn(), + startOwner: vi.fn(async () => false), + run: vi.fn(), + source: vi.fn(), + panes: vi.fn(() => []) +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: mocks.probe, + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })) +})) +vi.mock('@/runtime/structured-agent-session-owner', async (original) => ({ + ...(await original()), + resolveStructuredAgentSessionOwner: mocks.owner +})) +// Ordinary route feasibility has its own contracts; these tests exercise the History gates above it. +vi.mock('@/lib/agent-session-launch-plan', async (original) => ({ + ...(await original()), + structuredAgentSessionLaunchFeasible: () => true +})) +vi.mock('./ai-vault-session-resume-in-chat-launch', () => ({ + resumeAiVaultSessionInNewChat: mocks.newChat +})) +vi.mock('@/lib/conversation-surface-switch', () => ({ + runConversationSwitch: mocks.startOwner, + runCliChatResume: mocks.run, + useConversationSwitchReadiness: () => ({ ready: true, pending: false }) +})) +vi.mock('../tab-bar/cli-chat-resume-source', () => ({ resolveCliChatResumeSource: mocks.source })) +vi.mock('@/lib/ai-vault-tab-title-requests', async (original) => ({ + ...(await original()), + collectAgentPaneConversations: mocks.panes +})) + +const SWITCH = 'agent-session.structured.switch.creation-settlement-v1:codex' +const paired = { + id: 'paired', + name: 'Paired', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: 'paired', + preferredEndpointId: 'endpoint', + endpoints: [{ id: 'endpoint', kind: 'websocket' as const, label: 'Host', endpoint: 'ws://host' }] +} + +const row: AiVaultSession = { + id: 'codex-history', + agent: 'codex', + sessionId: 'conversation', + executionHostId: 'local', + structuredSession: { sessionId: 'owner', workspaceId: 'workspace' }, + title: 'Conversation', + cwd: '/repo', + branch: null, + model: null, + filePath: '/rollout.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-21T00:00:00Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [{ role: 'user', text: 'hello', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null +} +function History({ session }: { session: AiVaultSession }) { + const state = useAppStore.getState() + const targetState = { + ...state, + repos: [], + worktreesByRepo: { + repo: [makeWorktree({ id: 'workspace', repoId: 'repo', path: '/repo', hostId: 'local' })] + }, + folderWorkspaces: [], + projectGroups: [] + } + const resume = useAiVaultPanelSessionResume({ + sessions: [session], + repos: [], + allWorktrees: [], + workspaceId: 'workspace', + resumeTargetState: targetState, + settings: createGlobalSettingsFixture({ ...state.settings, experimentalNativeChat: true }) + }) + const actions = useAiVaultSessionLaunchActions({ + activeWorktree: null, + activeWorktreeId: 'workspace', + targetState + }) + return ( + + null} + isStructuredSessionOpen={() => false} + getSessionLiveState={() => null} + getWorktreeInfo={resume.getSessionWorktreeInfo} + getSessionResumeState={resume.getSessionResumeState} + getSessionResumeActions={resume.getSessionResumeActions} + getSessionResumeInChat={resume.getSessionResumeInChat} + onToggleGroup={vi.fn()} + onToggleSessionDetails={vi.fn()} + onJumpToOriginalPane={vi.fn()} + onJumpToWorktree={vi.fn()} + onResume={vi.fn()} + onContinueInNewSession={vi.fn()} + onResumeInNewChat={actions.handleResumeInNewChat} + onResumeInNewCli={mocks.fork} + onCopyResume={vi.fn()} + onCopyId={vi.fn()} + onCopyPath={vi.fn()} + onOpenLog={vi.fn()} + onRevealLog={vi.fn()} + onOpenCwd={vi.fn()} + onRequestDelete={vi.fn()} + /> + + ) +} +beforeEach(async () => { + vi.clearAllMocks() + Object.assign(window, { + api: { + ...window.api, + aiVault: {}, + runtime: { getStatus: async () => ({ capabilities: [HISTORY, SWITCH] }) } + } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) }) + setLocalRuntimeCapabilitiesForTests([HISTORY, SWITCH]) + await refreshLocalRuntimeCapabilities() + mocks.owner.mockReturnValue('local') + mocks.probe.mockResolvedValue(true) + mocks.source.mockReturnValue(null) + mocks.panes.mockReturnValue([]) +}) +afterEach(cleanup) + +function pairedHistoryStatus() { + const response = createCompatibleRuntimeStatusResponse('paired') + if (!response.ok) { + throw new Error('Expected status fixture') + } + return { status: { ...response.result, capabilities: [HISTORY, SWITCH] }, checkedAt: 1 } +} + +it.each(['local', 'runtime:paired'] as const)( + 'routes the owned %s History action to the common switcher', + async (host) => { + mocks.owner.mockReturnValue(host) + useAppStore.setState({ + runtimeEnvironments: [paired], + runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) + }) + render() + const button = await screen.findByRole('button', { name: 'Switch to native chat' }) + expect(button.hasAttribute('disabled')).toBe(false) + await userEvent.setup().click(button) + expect(mocks.startOwner).toHaveBeenCalledWith( + expect.objectContaining({ + direction: 'chat', + sessionId: 'owner', + workspaceId: 'workspace', + executionHostId: host + }) + ) + } +) + +vi.mock('@/components/confirmation-dialog-context', async (original) => ({ + ...(await original>()), + useConfirmationDialog: () => vi.fn(async () => false) +})) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts index 746e47c784fb..fb81b57309c8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from 'sonner' @@ -73,6 +74,7 @@ export function useAiVaultOriginalPaneActions(): { const jumpToOriginalPane = useCallback( async (session: AiVaultSession): Promise => { + noteViewerNavigation() if (session.structuredSession && isStructuredSessionOpen(session)) { return activateAiVaultStructuredSession(session) } @@ -102,6 +104,7 @@ export function useAiVaultOriginalPaneActions(): { ) const jumpToWorktree = useCallback((worktreeId: string): void => { + noteViewerNavigation() revealSessionWorkspace(worktreeId) }, []) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx index 227c2e77fdcb..b05f0c666407 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx @@ -1,5 +1,7 @@ import type React from 'react' import { translate } from '@/i18n/i18n' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' /** Use two blocks: balancing one wrapped string leaves a gap in the pre-sized tooltip. */ export function ResumeInNewCliTooltipText(): React.JSX.Element { @@ -20,3 +22,15 @@ export function ResumeInNewCliTooltipText(): React.JSX.Element { ) } + +export function SwitchToCliTooltipText({ agent }: { agent: AiVaultAgent }): React.JSX.Element { + return ( + <> + {translate( + 'components.tab.bar.switch.toCliTooltip', + 'Switch to CLI: stops {{agent}} in native chat and continues the same conversation in a terminal.', + { agent: agentLabel(agent) } + )} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts new file mode 100644 index 000000000000..f15a1518018b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts @@ -0,0 +1,120 @@ +import { collectAgentPaneConversations } from '@/lib/ai-vault-tab-title-requests' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' +import { + cliConversationRowsShareAgent, + adoptsCliConversations, + inPlaceSwitchEnabled +} from '../../../../shared/structured-agent-cli-conversation-adoption' +import { useAppStore } from '@/store' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { resolveCliChatResumeSource } from '../tab-bar/cli-chat-resume-source' +import { + runConversationSwitch, + runNativeChatCliSwitch, + type ConversationSwitch +} from '@/lib/conversation-surface-switch' + +/** History's "Switch to CLI": in the chat tab's slot when that tab is open in this window, else a + * new tab. False when the row's host doesn't switch this agent in place, so the row forks. */ +export function switchAiVaultSessionToCli( + session: AiVaultSession, + confirm?: ConfirmationDialogContextValue +): boolean { + const chat = session.structuredSession + if (!chat || !inPlaceSwitchEnabled(session.agent)) { + return false + } + const chatTab = useAppStore + .getState() + .unifiedTabsByWorktree[chat.workspaceId]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === chat.sessionId + ) + void runNativeChatCliSwitch({ + confirm, + sessionId: chat.sessionId, + workspaceId: chat.workspaceId, + executionHostId: session.executionHostId, + agent: session.agent, + chatTabId: chatTab?.id ?? null, + history: session + }) + return true +} + +/** History's "Switch to native chat": in place when a pane in this window last reported the + * conversation. False otherwise, so the row opens or reveals its chat in a new tab. */ +export function switchAiVaultSessionToNativeChat( + session: AiVaultSession, + workspaceId?: string, + originalPaneTarget?: AiVaultOriginalPaneTarget | null, + confirm?: ConfirmationDialogContextValue +): boolean { + if (!adoptsCliConversations(session.agent) || !inPlaceSwitchEnabled(session.agent)) { + return false + } + const source = historyPaneSource(session, originalPaneTarget) + if (!source) { + const subject = historyConversationSwitch(session, 'chat', workspaceId) + if (!subject) { + return false + } + void runConversationSwitch({ ...subject, confirm }) + return true + } + void runConversationSwitch({ direction: 'chat', ...source, source, history: session, confirm }) + return true +} + +/** History can address a closed chat without opening it merely to inspect availability. */ +export function historyConversationSwitch( + session: AiVaultSession, + direction: 'chat' | 'terminal', + workspaceId?: string, + originalPaneTarget?: AiVaultOriginalPaneTarget | null +): ConversationSwitch | null { + const chat = session.structuredSession + return chat || direction === 'chat' + ? { + direction, + agent: session.agent, + executionHostId: session.executionHostId, + workspaceId: chat?.workspaceId ?? workspaceId ?? '', + sessionId: chat?.sessionId, + history: session, + source: + direction === 'chat' + ? (historyPaneSource(session, originalPaneTarget) ?? undefined) + : undefined, + platform: session.executionHostPlatform ?? undefined + } + : null +} + +function historyPaneSource(session: AiVaultSession, pane?: AiVaultOriginalPaneTarget | null) { + const agent = session.agent + if (!adoptsCliConversations(agent)) { + return null + } + const location = + pane ?? + collectAgentPaneConversations(useAppStore.getState(), adoptsCliConversations).find( + (candidate) => + candidate.executionHostId === session.executionHostId && + candidate.providerSession.id === session.sessionId && + cliConversationRowsShareAgent(candidate.agent, agent) + ) + if (!location) { + return null + } + const source = resolveCliChatResumeSource(useAppStore.getState(), { + tab: { id: location.tabId, worktreeId: location.worktreeId }, + paneKey: location.paneKey + }) + return source && + source.executionHostId === session.executionHostId && + source.providerSessionId === session.sessionId && + cliConversationRowsShareAgent(source.agent, agent) + ? source + : null +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index a4d0bd2accee..e2fd62895648 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -5,6 +5,7 @@ import type * as ForkStartup from '@/lib/ai-vault-session-fork-startup' // @vitest-environment happy-dom import { cleanup, render, fireEvent, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as sessionSwitch from './ai-vault-session-in-place-switch' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session-types' @@ -17,6 +18,8 @@ const mocks = vi.hoisted(() => ({ prepareAiVaultSessionForTerminalResume: vi.fn(), launchAiVaultSessionInNewTab: vi.fn(), activateAiVaultResumeWorkspace: vi.fn(), + switchToCli: vi.fn(async () => {}), + confirm: vi.fn(async () => false), rpc: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn() @@ -46,6 +49,10 @@ vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launchAiVaultSessionInNewTab })) +vi.mock('@/lib/conversation-surface-switch', () => ({ + runNativeChatCliSwitch: mocks.switchToCli, + runConversationSwitch: vi.fn(async () => false) +})) vi.mock('./ai-vault-session-launch-target', () => ({ resolveAiVaultSessionLaunchTarget: (args: { targetWorktreeId?: string }) => ({ status: 'ready', @@ -109,7 +116,11 @@ function renderActions(agentCmdOverrides?: { opencode?: string }) { beforeEach(() => { vi.resetAllMocks() - mocks.getState.mockReturnValue({ activeWorktreeId: 'worktree-1', settings: {} }) + mocks.getState.mockReturnValue({ + activeWorktreeId: 'worktree-1', + settings: {}, + unifiedTabsByWorktree: {} + }) mocks.prepareAiVaultSessionForFork.mockImplementation(async (session) => ({ session, copied: false @@ -119,9 +130,16 @@ beforeEach(() => { mocks.rpc.mockResolvedValue(undefined) }) -afterEach(() => cleanup()) +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ordinary CLI copy launch when the switch action does not apply', () => { + beforeEach(() => { + vi.spyOn(sessionSwitch, 'switchAiVaultSessionToCli').mockReturnValue(false) + }) -describe('Switch to Terminal launch', () => { // Either OpenCode chat may own a 2.x conversation, so the copy may open in either CLI. it.each([ ['command', 'opencode'], @@ -241,6 +259,7 @@ describe('Switch to Terminal launch', () => { }) it('refuses the stale OMP row before creating a second terminal writer', async () => { + vi.clearAllMocks() mocks.prepareAiVaultSessionForTerminalResume.mockRejectedValue( new Error( "Error invoking remote method 'aiVault:prepareSessionResume': Error: agent_session_conflict" @@ -257,6 +276,11 @@ it('refuses the stale OMP row before creating a second terminal writer', async ( expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled() }) +vi.mock('@/components/confirmation-dialog-context', async (original) => ({ + ...(await original>()), + useConfirmationDialog: () => mocks.confirm +})) + it('intercepts an ordinary structured Resume and actual pane drop on handled refusal without terminal execution', async () => { mocks.getState.mockReturnValue({ activeWorktreeId: 'worktree-1', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 7dc65af34910..018be48a8df5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -1,3 +1,6 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' import { notifyAiVaultSessionPreparationFailure, resolveAiVaultSessionLaunchTargetOrNotify @@ -35,6 +38,10 @@ import { adoptsCliConversations } from '../../../../shared/structured-agent-cli- import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch' +import { + switchAiVaultSessionToCli, + switchAiVaultSessionToNativeChat +} from './ai-vault-session-in-place-switch' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' export function useAiVaultSessionLaunchActions({ @@ -48,6 +55,7 @@ export function useAiVaultSessionLaunchActions({ targetState: AiVaultSessionResumeTargetState agentCmdOverrides?: Partial> }) { + const confirm = useConfirmationDialog() const [continuationRequest, setContinuationRequest] = useState(null) @@ -127,6 +135,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { + noteViewerNavigation() if (session.structuredSession) { void activateAiVaultStructuredSession(session) return @@ -144,9 +153,13 @@ export function useAiVaultSessionLaunchActions({ [buildResumeStartup, launchInTerminal] ) - // Native chat keeps the conversation it owns; the terminal gets a copy (see the fork builder). + // Supported chats transfer the same conversation; other history rows keep their ordinary resume. const handleResumeInNewCli = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { + noteViewerNavigation() + if (switchAiVaultSessionToCli(session, confirm)) { + return + } const prepareStartup = async (worktreeId: string): Promise => { assertAiVaultCopiedSessionLaunchAllowed({ state: useAppStore.getState(), @@ -185,12 +198,25 @@ export function useAiVaultSessionLaunchActions({ } launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure) }, - [agentCmdOverrides, launchInTerminal] + [agentCmdOverrides, launchInTerminal, confirm] ) const handleResumeInNewChat = useCallback( - (session: AiVaultSession, targetWorktreeId?: string): void => { - if (!adoptsCliConversations(session.agent)) { + ( + session: AiVaultSession, + targetWorktreeId?: string, + pane?: AiVaultOriginalPaneTarget | null + ): void => { + noteViewerNavigation() + if ( + !adoptsCliConversations(session.agent) || + switchAiVaultSessionToNativeChat( + session, + targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id, + pane, + confirm + ) + ) { return } const worktreeId = targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null @@ -210,7 +236,7 @@ export function useAiVaultSessionLaunchActions({ newAgentLaunchRequestId() ) }, - [activeWorktree?.id, activeWorktreeId] + [activeWorktree?.id, activeWorktreeId, confirm] ) const handleContinueInNewSession = useCallback( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts index 832cec2a84b8..7c1a0c68f38e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts @@ -8,13 +8,14 @@ import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' import type { AiVaultAgent } from '../../../../shared/ai-vault-types' -export function notifyAiVaultSessionQueued(agent: AiVaultAgent): void { +export function notifyAiVaultSessionQueued(agent: AiVaultAgent, message?: string): void { toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', - '{{value0}} session queued', - { value0: agentLabel(agent) } - ) + message ?? + translate( + 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', + '{{value0}} session queued', + { value0: agentLabel(agent) } + ) ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index c731e1fd8ba6..73bcdaa7e174 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -268,7 +268,7 @@ describe('resumeAiVaultSessionInNewChat', () => { [ 'agent_session_conflict', 'conversationHeldElsewhere', - 'Codex is already running this conversation in another terminal. Quit Codex there or close that terminal, then open this conversation from Session History.' + 'Orca could not confirm Codex stopped. Quit Codex in its terminal, then try this action again.' ], [ 'agent_session_identity_required', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index 99ceb2a35e2c..07f29a2cc1d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -1,3 +1,5 @@ +import { isSessionTabSelectionRequest } from '../../../../shared/session-tab-placement' +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' import { toast } from 'sonner' import { parseRoutableExecutionHostId, @@ -55,7 +57,10 @@ export async function resumeAiVaultSessionInNewChat( rowAgent: CliConversationRowAgent, worktreeId: string, requestId: AgentLaunchRequestId, - replacing?: AiVaultChatReplacement + replacing?: AiVaultChatReplacement, + viewerSelection?: string, + reportFailure: (error: unknown) => void = (error) => + notifyAiVaultSessionResumeInChatFailure(error, rowAgent) ): Promise { try { const recordingHost = parseRoutableExecutionHostId(session.executionHostId) @@ -86,12 +91,22 @@ export async function resumeAiVaultSessionInNewChat( const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, + viewerSelection, + ...(viewerSelection !== undefined + ? { canActivate: () => conversationSwitchViewerSelectionCurrent(viewerSelection) } + : {}), ...(replacing ? { afterTabId: replacing.afterTabId, focusIntent: replacing.focusIntent } : {}), beforeOpen: waitForCreate ? undefined : () => { + if ( + isSessionTabSelectionRequest(viewerSelection) || + !conversationSwitchViewerSelectionCurrent(viewerSelection) + ) { + return true + } if ( useAppStore.getState().activeWorktreeId !== worktreeId || structuredLaunchOwner({ route: 'structured-native-chat' }, worktreeId) @@ -109,6 +124,8 @@ export async function resumeAiVaultSessionInNewChat( void launch.settlement.then((settlement) => { const state = useAppStore.getState() if ( + conversationSwitchViewerSelectionCurrent(viewerSelection) && + !isSessionTabSelectionRequest(viewerSelection) && waitForCreate && settlement.kind === 'structured' && state.activeWorktreeId !== worktreeId && @@ -124,11 +141,11 @@ export async function resumeAiVaultSessionInNewChat( } const handled = replacing?.onSettled(settlement) === true if (settlement.kind === 'failed' && !settlement.notified && !handled) { - notifyAiVaultSessionResumeInChatFailure(settlement.error, rowAgent) + reportFailure(settlement.error) } }) } catch (error) { - notifyAiVaultSessionResumeInChatFailure(error, rowAgent) + reportFailure(error) replacing?.onSettled(null) } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index f800ab2219ed..71c43cd1b97e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({ state: { runtimeStatusByEnvironmentId: new Map() } })) +vi.mock('@/lib/in-place-switch-support', () => ({ + inPlaceSwitchCapabilityNow: () => false, + inPlaceSwitchSupportedNow: () => false +})) + vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) vi.mock('@/runtime/structured-agent-session-owner', () => ({ resolveStructuredAgentSessionOwner: mocks.owner diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index 8741bbdf86d6..fcf86ba52c3e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -1,3 +1,4 @@ +import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import { resolveHostCapabilities, workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { structuredAgentSessionLaunchFeasible, @@ -9,7 +10,8 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { adoptsCliConversations, - CLI_CONVERSATION_ADOPTING_AGENTS + CLI_CONVERSATION_ADOPTING_AGENTS, + inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, @@ -35,9 +37,11 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { targetState: AiVaultSessionResumeTargetState settings: AgentSessionStructuredFeasibilityRequest['settings'] }): AiVaultResumeInChatEligibility { - const targetWorkspaceId = args.resumeState.usesSessionWorktree - ? args.resumeState.worktreeId - : (args.resumeState.worktreeId ?? args.activeWorkspaceId) + const targetWorkspaceId = + args.session.structuredSession?.workspaceId ?? + (args.resumeState.usesSessionWorktree + ? args.resumeState.worktreeId + : (args.resumeState.worktreeId ?? args.activeWorkspaceId)) const targetWorkspacePath = targetWorkspaceId ? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId) : null @@ -55,25 +59,30 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { session: args.session, targetWorkspaceId, targetWorkspacePath, - structuredRouteAvailable: - adoptsCliConversations(args.session.agent) && - targetWorkspaceId !== null && + inPlaceSwitchAvailable: targetOwner !== null && targetOwner === normalizeExecutionHostId(args.session.executionHostId) && - CLI_CONVERSATION_ADOPTING_AGENTS[args.session.agent].some((agent) => - structuredAgentSessionLaunchFeasible(state, { - agent, - workspace: { - kind: workspaceKindForWorktreeId(targetWorkspaceId), - worktreeId: targetWorkspaceId - }, - settings: args.settings - }) - ) && - (!aliasDependent || - capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY) === - true) && - capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY) === true + inPlaceSwitchSupportedNow(targetOwner, args.session.agent), + structuredRouteAvailable: + Boolean(args.session.structuredSession && inPlaceSwitchEnabled(args.session.agent)) || + (adoptsCliConversations(args.session.agent) && + targetWorkspaceId !== null && + targetOwner !== null && + targetOwner === normalizeExecutionHostId(args.session.executionHostId) && + CLI_CONVERSATION_ADOPTING_AGENTS[args.session.agent].some((agent) => + structuredAgentSessionLaunchFeasible(state, { + agent, + workspace: { + kind: workspaceKindForWorktreeId(targetWorkspaceId), + worktreeId: targetWorkspaceId + }, + settings: args.settings + }) + ) && + (!aliasDependent || + capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY) === + true) && + capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY) === true) }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index 59c34b843ae3..120d4a6fe69b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -65,13 +65,16 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { targetWorkspacePath: string | null /** Reuse structured route capability/platform/WSL gates to prevent drift; the Chat UI switch does not apply. */ structuredRouteAvailable: boolean + /** Unknown switch capability keeps an eligible disabled action on its existing surface. */ + inPlaceSwitchAvailable?: boolean }): AiVaultResumeInChatEligibility { const { session } = args if (isAntigravityReferenceSession(session) || !adoptsCliConversations(session.agent)) { return { available: false, reason: 'agent' } } - // Already-adopted rows reopen their owner; a second adoption would conflict. - if (session.structuredSession) { + const canSwitch = + args.inPlaceSwitchAvailable === true && !isWslStoredAiVaultSessionFile(session.filePath) + if (session.structuredSession && !canSwitch) { return { available: false, reason: 'already-structured' } } const recordingHost = parseRoutableExecutionHostId(session.executionHostId) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts index d7ec6d158c34..50b12b481f03 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts @@ -25,7 +25,7 @@ export function useAiVaultPanelSessionResume(args: { settings: AppState['settings'] }) { const { sessions, repos, allWorktrees, workspaceId, resumeTargetState, settings } = args - // Folder workspaces also own sessions by cwd for resume and jump. + // Folder workspaces own their sessions by cwd too, so they resume and jump like a worktree. const ownerWorkspaces = useAiVaultSessionOwnerWorkspaces(allWorktrees, resumeTargetState) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, @@ -63,13 +63,12 @@ export function useAiVaultPanelSessionResume(args: { }), [getSessionRowResumeArgs] ) - // Chat resume must preserve the session’s workspace: Claude finds transcripts through launch-cwd-derived paths. - const getSessionResumeInChat = useCallback( - (session: AiVaultSession): AiVaultResumeInChatEligibility => - resolveAiVaultHistoryRowResume({ ...getSessionRowResumeArgs(session), settings }) - .resumeInChat, - [getSessionRowResumeArgs, settings] - ) + // Resuming into a chat asks a different question from resuming into a terminal: not "can this + // workspace host a PTY" but "will the provider still find this conversation from the workspace we + // would run it in". The workspace it targets is the session's own when that is open, because + // Claude looks its transcript up under a directory derived from the launch cwd. + const getSessionResumeInChat = (session: AiVaultSession): AiVaultResumeInChatEligibility => + resolveAiVaultHistoryRowResume({ ...getSessionRowResumeArgs(session), settings }).resumeInChat return { getSessionWorktreeInfo, getSessionResumeState, diff --git a/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts index cab92c7de0f7..a52a61169f75 100644 --- a/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts +++ b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' import type { ActiveRightSidebarTab } from '@/store/slices/editor' @@ -79,6 +80,7 @@ export function useRightSidebarTabRouting({ rememberedFolderTabByWorkspaceKeyRef.current[activeFolderWorkspaceKey] = effectiveTab }, [activeFolderWorkspaceKey, effectiveTab, visibleItems]) const selectActivityTab = (tab: ActiveRightSidebarTab): void => { + noteViewerNavigation() if (activeFolderWorkspaceKey) { rememberedFolderTabByWorkspaceKeyRef.current[activeFolderWorkspaceKey] = tab } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index 18c92059f73b..2837b7a51b67 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import type React from 'react' import type { RefObject } from 'react' @@ -89,6 +90,7 @@ export async function activateFileExplorerNode(args: { toggleDir(activeWorktreeId, node.path) return } + noteViewerNavigation() let escapesWorktree = false if (node.isSymlink) { // Resolve symlinks only on explicit activation because targets may be protected by macOS TCC. diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx index 61b8267a50a4..638216044f73 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import React from 'react' import { Terminal } from 'lucide-react' import { useAppStore } from '@/store' @@ -29,6 +30,7 @@ function useReferenceTabs(workspace: Worktree): Tab[] { } function focusOrigin(workspace: Worktree, tab: Tab, paneKey: string | undefined): void { + noteViewerNavigation() const state = useAppStore.getState() if (!state.setActiveWorktree(workspace.id, workspace.hostId)) { return diff --git a/src/renderer/src/components/sidebar/activate-retained-agent-row.ts b/src/renderer/src/components/sidebar/activate-retained-agent-row.ts index 9b335088379f..f57f92d250dc 100644 --- a/src/renderer/src/components/sidebar/activate-retained-agent-row.ts +++ b/src/renderer/src/components/sidebar/activate-retained-agent-row.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useAppStore } from '@/store' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' @@ -34,6 +35,7 @@ export function activateRetainedAgentRow(worktreeId: string, tabId: string, pane if ((nativeTab ? !target : !hasLiveTerminal) || owner === 'foreign') { return } + noteViewerNavigation() // Verify an existing destination before workspace activation can resume sleeping panes. if ( activateAndRevealWorkspace(worktreeId, owner ? { executionHostId: owner } : undefined) === false diff --git a/src/renderer/src/components/sidebar/use-worktree-agent-activation.ts b/src/renderer/src/components/sidebar/use-worktree-agent-activation.ts index c19404427c44..06f58946d953 100644 --- a/src/renderer/src/components/sidebar/use-worktree-agent-activation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-agent-activation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -25,6 +26,7 @@ export function useWorktreeAgentActivation(worktreeId: string) { dismissStaleAgentRowByKey(paneKey) return } + noteViewerNavigation() // Use shared activation for cross-repo reveal and navigation history. activateAndRevealWorktree(worktreeId) const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/components/status-bar/resource-session-navigation.ts b/src/renderer/src/components/status-bar/resource-session-navigation.ts index 94f11f862c8e..1a1ff7f7a8d5 100644 --- a/src/renderer/src/components/status-bar/resource-session-navigation.ts +++ b/src/renderer/src/components/status-bar/resource-session-navigation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { parsePaneKey } from '../../../../shared/stable-pane-id' type TabLookup = Record @@ -22,6 +23,7 @@ export function navigateResourceSessionToTab( paneKey: string | null, deps: ResourceSessionNavigationDeps ): void { + noteViewerNavigation() for (const [worktreeId, tabs] of Object.entries(deps.tabsByWorktree)) { if (tabs.some((tab) => tab.id === tabId)) { deps.activateAndRevealWorktree(worktreeId) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts index 502995f714de..dbd260e66846 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback, useState, @@ -103,6 +104,7 @@ export function useResourceUsageActions({ } // Folder activation owns host/path gating; git-worktree target resolution cannot handle folder keys. if (parseWorkspaceKey(worktreeId)?.type === 'folder') { + noteViewerNavigation() activateAndRevealWorkspace(worktreeId) return } @@ -113,6 +115,7 @@ export function useResourceUsageActions({ if (!target) { return } + noteViewerNavigation() activateAndRevealWorktree(worktreeId, { executionHostId: target.hostId }) }, []) diff --git a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx index 34c47cca7add..7da70264ed36 100644 --- a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useConversationSwitchReadiness, runCliChatResume } from '@/lib/conversation-surface-switch' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems' import { resolveCliChatResumeSource } from './cli-chat-resume-source' -import { runCliChatResume, useCliChatResumePending } from './cli-chat-resume-run' export function CliChatResumeMenuItems({ tab, @@ -15,10 +17,13 @@ export function CliChatResumeMenuItems({ leadingSeparator: boolean onComplete?: () => void }): React.JSX.Element | null { - const [source] = useState(() => resolveCliChatResumeSource(useAppStore.getState(), { tab })) - const pending = useCliChatResumePending(source) - // Radix disables pointer events on disabled items, so a tooltip-less "update" item would only confuse. - if (!source || source.hostUpdateRequired) { + const source = useAppStore(useShallow((state) => resolveCliChatResumeSource(state, { tab }))) + const confirm = useConfirmationDialog() + const readiness = useConversationSwitchReadiness( + source ? { direction: 'chat', ...source, source } : null + ) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!source || !enabled || source.hostUpdateRequired) { return null } return ( @@ -26,12 +31,14 @@ export function CliChatResumeMenuItems({ {leadingSeparator ? : null} { event.preventDefault() - void runCliChatResume(source).finally(() => onComplete?.()) + void runCliChatResume(source, confirm).finally(() => onComplete?.()) }} /> diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx index bf17bbc72587..91b7c901ff6a 100644 --- a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -1,14 +1,21 @@ -import { useState } from 'react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { + useConversationSwitchReadiness, + runNativeChatCliSwitch +} from '@/lib/conversation-surface-switch' +import { useShallow } from 'zustand/react/shallow' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems' import { - resolveTabSessionHistorySubject, + resolveNativeChatCliForkIdentity, type NativeChatTabSession } from './tab-session-history-switch' -import { runNativeChatCliFork, useNativeChatCliForkPending } from './native-chat-cli-fork-run' +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +/** Every supported chat switch uses the same consent and owning-host command. */ export function NativeChatCliForkMenuItems({ tab, nativeChat, @@ -20,16 +27,25 @@ export function NativeChatCliForkMenuItems({ leadingSeparator: boolean onComplete?: () => void }): React.JSX.Element | null { - const [subject] = useState(() => - resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) - ) - const chat = subject?.kind === 'chat' ? subject : null - const pending = useNativeChatCliForkPending( - chat ? { executionHostScope: chat.request.executionHostScope, sessionId: chat.sessionId } : null + const chat = useAppStore( + useShallow((state) => resolveNativeChatCliForkIdentity(state, { tab, nativeChat })) ) - const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) - if (!chat || !agent) { + const confirm = useConfirmationDialog() + const readiness = useConversationSwitchReadiness( + chat && agent + ? { + direction: 'terminal', + sessionId: chat.sessionId, + agent, + workspaceId: chat.workspaceId, + executionHostId: chat.executionHostScope, + chatTabId: tab.id + } + : null + ) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!chat || !agent || !enabled || !inPlaceSwitchEnabled(agent)) { return null } return ( @@ -39,10 +55,21 @@ export function NativeChatCliForkMenuItems({ action="switch" menuKind="dropdown" tooltipSide="right" - cliPending={pending} + cliPending={readiness.pending} + switchLoading={!readiness.ready} + switchExplanation={readiness.explanation} + inPlaceAgent={agent} onResumeInNewCli={(event) => { event.preventDefault() - void runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides, onComplete }) + void runNativeChatCliSwitch({ + confirm, + sessionId: chat.sessionId, + workspaceId: chat.workspaceId, + executionHostId: chat.executionHostScope, + agent, + chatTabId: tab.id, + onComplete + }) }} /> diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 4396869e81eb..de519c217cdc 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -67,6 +67,7 @@ vi.mock('react', async () => { vi.mock('./TabSessionSurfaceSwitchMenuItems', () => ({ TabSessionSurfaceSwitchMenuItems: () => null })) +vi.mock('./TabSessionSwitchHoverAction', () => ({ TabSessionSwitchHoverAction: () => null })) vi.mock('./use-tab-strip-slot-props', () => ({ useTabStripSlotProps: () => ({ className: '', 'data-tab-strip-slot': '' }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e038ce6d7c27..bdc2e53122d5 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -6,6 +6,7 @@ import { getAgentLabel } from '@/lib/agent-catalog' import { basename } from '@/lib/path' import { Input } from '@/components/ui/input' import { TabHoverCard } from './TabHoverCard' +import { TabSessionSwitchHoverAction } from './TabSessionSwitchHoverAction' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../../shared/terminal-agent' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -27,10 +28,7 @@ import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' -import { - isTerminalTabActivityLive, - terminalTabHasUnreadActivity -} from './terminal-tab-activity-status' +import * as tabActivity from './terminal-tab-activity-status' import { useTerminalTabActivity } from './use-terminal-tab-activity' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import type { NativeChatTabSession } from './tab-session-history-switch' @@ -113,7 +111,7 @@ export default function SortableTab({ const naming = useNativeChatNamingFailure(namingFailure) // Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render. const hasUnreadActivity = useAppStore((s) => - terminalTabHasUnreadActivity({ + tabActivity.terminalTabHasUnreadActivity({ terminalTabId: tab.id, unreadTerminalTabs: s.unreadTerminalTabs, unreadAgentCompletionPanes: s.unreadAgentCompletionPanes @@ -167,7 +165,7 @@ export default function SortableTab({ }) // Why: a live working/needs-input state is newer than a prior-turn unread, so it owns the icon until the turn ends. const showUnreadActivity = - hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus) + hasUnreadActivity && !isEditing && !tabActivity.isTerminalTabActivityLive(activityStatus) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -368,6 +366,7 @@ export default function SortableTab({ tabRoot ) : ( } title={displayTitle} programName={ tabAgent diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 7953fe660e69..b2f71434d2f1 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -270,14 +270,14 @@ describe('SortableTabContextMenu', () => { it('does not expose a native/terminal view switch', () => { const { container } = renderMenu() - expect(container.textContent).not.toContain('Continue with Terminal Agent') - expect(container.textContent).not.toContain('Continue in Chat UI') + expect(container.textContent).not.toContain('Show as terminal') + expect(container.textContent).not.toContain('Show as chat') }) it('hides switching a terminal-view tab into chat view', () => { const { container } = renderMenu({ canToggleViewMode: true, onToggleViewMode: vi.fn() }) - expect(container.textContent).not.toContain('Continue in Chat UI') + expect(container.textContent).not.toContain('Show as chat') }) it('keeps the way back for a tab already in chat view, in the chat/CLI move section', () => { @@ -289,11 +289,11 @@ describe('SortableTabContextMenu', () => { }) const marker = getSurfaceSwitchMarker(container) - expect( - getButton(container, 'Continue with Terminal Agent').compareDocumentPosition(marker) - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(getButton(container, 'Show as terminal').compareDocumentPosition(marker)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) expect(marker.getAttribute('data-leading-separator')).toBe('false') - act(() => getButton(container, 'Continue with Terminal Agent').click()) + act(() => getButton(container, 'Show as terminal').click()) expect(onToggleViewMode).toHaveBeenCalled() }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 050a1293f8f2..a9b558863062 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -174,8 +174,8 @@ export function SortableTabContextMenu({ {translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Continue with Terminal Agent' + 'components.tab.bar.SortableTabContextMenu.showAsTerminal', + 'Show as terminal' )} diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx index 18c10a5466b7..b254fc19d17b 100644 --- a/src/renderer/src/components/tab-bar/TabHoverCard.tsx +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -1,14 +1,47 @@ +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { useState, type ReactNode, type SyntheticEvent } from 'react' import { flushSync } from 'react-dom' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -export function TabHoverCard({ - children, +function TabHoverCardBody({ title, icon, programName, description, + action, alert +}: { + title: string + icon: ReactNode + programName: string + description?: string + alert?: ReactNode + action?: ReactNode +}): React.JSX.Element { + return ( +
+
+
+ {title} +
+ {description &&
{description}
} +
+
+ + {icon} + + {programName} +
+ {alert} + {action} +
+ ) +} + +/** `action` makes the card a hover card the pointer can enter, so its control is clickable. */ +export function TabHoverCard({ + children, + ...body }: { children: ReactNode title: string @@ -17,11 +50,34 @@ export function TabHoverCard({ description?: string /** Controls below the program row; the card then stays open under the pointer. */ alert?: ReactNode | ((dismiss: () => void) => ReactNode) + action?: ReactNode }): React.JSX.Element { const [open, setOpen] = useState(false) // Remove the portal before navigation can start rendering a slower destination. const dismiss = () => flushSync(() => setOpen(false)) - const interactive = alert !== undefined && alert !== null + const alert = typeof body.alert === 'function' ? body.alert(dismiss) : body.alert + if (body.action) { + return ( + + {children} + + + + + ) + } + const interactive = body.alert !== undefined && body.alert !== null return ( -
-
-
- {title} -
- {description &&
{description}
} -
-
- - {icon} - - {programName} -
- {typeof alert === 'function' ? alert(dismiss) : alert} -
+
) diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index 357826f493bf..4a204255f465 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -1,4 +1,8 @@ +vi.mock('@/lib/in-place-switch-support', () => ({ useInPlaceSwitchCapability: () => false })) // @vitest-environment happy-dom +import { useAppStore } from '@/store' +import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { useSyncExternalStore } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,6 +24,7 @@ const mocks = vi.hoisted(() => { pending: false, pendingListeners: new Set<() => void>(), runNativeChatCliFork: vi.fn(), + runNativeChatCliSwitch: vi.fn(), runCliChatResume: vi.fn(async () => {}), listSessions: vi.fn() } @@ -37,22 +42,28 @@ vi.mock('./native-chat-cli-fork-run', () => ({ ) })) -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: Record) => unknown) => selector({ settings: null }), - { getState: () => ({}) } - ) -})) - vi.mock('./tab-session-history-switch', async (importOriginal) => ({ ...(await importOriginal>()), resolveTabSessionHistorySubject: () => mocks.subject })) -vi.mock('./cli-chat-resume-source', () => ({ resolveCliChatResumeSource: () => mocks.cliSource })) -vi.mock('./cli-chat-resume-run', () => ({ +vi.mock('./cli-chat-resume-source', () => ({ + useCliChatResumeCapability: (_worktreeId: string, agent: string) => agent === 'codex', + resolveCliChatResumeSource: () => mocks.cliSource +})) +vi.mock('@/lib/conversation-surface-switch', () => ({ runCliChatResume: mocks.runCliChatResume, - useCliChatResumePending: () => false + runNativeChatCliSwitch: mocks.runNativeChatCliSwitch, + useConversationSwitchReadiness: () => { + const pending = useSyncExternalStore( + (listener) => { + mocks.pendingListeners.add(listener) + return () => mocks.pendingListeners.delete(listener) + }, + () => mocks.pending + ) + return { ready: !pending, pending } + } })) const CHAT_SUBJECT = { @@ -63,10 +74,17 @@ const CHAT_SUBJECT = { } beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }), + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree('wt-1', 'Worktree', { path: '/repo/wt' })] } + }) mocks.subject = null mocks.cliSource = null mocks.pending = false mocks.runNativeChatCliFork.mockReset() + mocks.runNativeChatCliSwitch.mockReset() mocks.runCliChatResume.mockClear() mocks.listSessions.mockReset() mocks.listSessions.mockReturnValue(new Promise(() => {})) @@ -115,11 +133,12 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { mocks.subject = CHAT_SUBJECT renderItemsNow('orca-chat-1') expect(mocks.listSessions).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('menuitem', { name: 'Continue with Terminal Agent' })) - expect(mocks.runNativeChatCliFork).toHaveBeenCalledWith( + fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to CLI' })) + expect(mocks.runNativeChatCliSwitch).toHaveBeenCalledWith( expect.objectContaining({ - subject: CHAT_SUBJECT, - tab: { id: 'tab-1', worktreeId: 'wt-1' }, + sessionId: 'orca-chat-1', + workspaceId: 'wt-1', + chatTabId: 'tab-1', agent: 'claude' }) ) @@ -136,23 +155,28 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { } mocks.cliSource = source renderItemsNow() - fireEvent.click(screen.getByRole('menuitem', { name: 'Continue in Chat UI' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to native chat' })) await act(async () => {}) - expect(mocks.runCliChatResume).toHaveBeenCalledWith(source) + expect(mocks.runCliChatResume).toHaveBeenCalledWith(source, expect.any(Function)) expect(mocks.listSessions).not.toHaveBeenCalled() }) }) it('keeps an accepted native action visible with shared disabled spinner feedback', async () => { mocks.subject = CHAT_SUBJECT - mocks.runNativeChatCliFork.mockImplementation(() => { + mocks.runNativeChatCliSwitch.mockImplementation(() => { mocks.pending = true mocks.pendingListeners.forEach((listener) => listener()) }) await renderItems('orca-chat-1') - fireEvent.click(screen.getByRole('menuitem', { name: 'Continue with Terminal Agent' })) - const item = screen.getByRole('menuitem', { name: 'Continue with Terminal Agent' }) + fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to CLI' })) + const item = screen.getByRole('menuitem', { name: 'Switch to CLI' }) expect(item.getAttribute('aria-busy')).toBe('true') expect(item.getAttribute('data-disabled')).not.toBeNull() expect(item.querySelector('.animate-spin')).not.toBeNull() }) + +vi.mock('@/components/confirmation-dialog-context', async (original) => ({ + ...(await original>()), + useConfirmationDialog: () => vi.fn(async () => false) +})) diff --git a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx new file mode 100644 index 000000000000..fc6915bfd964 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx @@ -0,0 +1,115 @@ +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { + useConversationSwitchReadiness, + runCliChatResume, + runNativeChatCliSwitch +} from '@/lib/conversation-surface-switch' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' +import { useShallow } from 'zustand/react/shallow' +import { MessagesSquare, SquareTerminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { resolveCliChatResumeSource } from './cli-chat-resume-source' +import { + resolveNativeChatCliForkIdentity, + type NativeChatTabSession +} from './tab-session-history-switch' + +/** The tab's in-place switch inside its hover card; mounted only while the card is open. */ +export function TabSessionSwitchHoverAction({ + tab, + nativeChat +}: { + tab: { id: string; worktreeId: string } + nativeChat?: NativeChatTabSession +}): React.JSX.Element | null { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!enabled) { + return null + } + return nativeChat ? ( + + ) : ( + + ) +} + +function ChatSwitchAction({ + tab, + nativeChat +}: { + tab: { id: string; worktreeId: string } + nativeChat: NativeChatTabSession +}): React.JSX.Element | null { + const chat = useAppStore( + useShallow((state) => resolveNativeChatCliForkIdentity(state, { tab, nativeChat })) + ) + const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + const confirm = useConfirmationDialog() + const readiness = useConversationSwitchReadiness( + chat && agent + ? { + direction: 'terminal', + sessionId: chat.sessionId, + agent, + workspaceId: chat.workspaceId, + executionHostId: chat.executionHostScope, + chatTabId: tab.id + } + : null + ) + if (!chat || !agent || !inPlaceSwitchEnabled(agent)) { + return null + } + return ( + + ) +} + +function TerminalSwitchAction({ + tab +}: { + tab: { id: string; worktreeId: string } +}): React.JSX.Element | null { + const source = useAppStore(useShallow((state) => resolveCliChatResumeSource(state, { tab }))) + const confirm = useConfirmationDialog() + const readiness = useConversationSwitchReadiness( + source ? { direction: 'chat', ...source, source } : null + ) + if (!source || !inPlaceSwitchEnabled(source.agent)) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 1807c15f9c3f..43ac60bdcc7c 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -1,70 +1,153 @@ +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' +import { conversationSwitchFailureText } from '@/lib/conversation-switch-feedback' +import { useAppStore } from '@/store' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getIndexedWorktreeById } from '@/store/worktree-repo-index' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' -import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' -import { agentSessionWriteFailureText } from '../native-chat/agent-session-write-notice-text' -import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import { + activateAiVaultStructuredSession, + activateAiVaultStructuredSessionVerified +} from '@/lib/activate-ai-vault-structured-session' +import { + callStructuredAgentSession, + supportsInPlaceSwitch +} from '@/runtime/structured-agent-session-client' +import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' import type { CliChatResumeSource } from './cli-chat-resume-source' +/** True only when the chat already holding the conversation took it over: the host started its + * agent (catching up on the terminal's turns) and its tab is active. Otherwise the terminal stays. */ export async function reportCliChatResumeFailure( source: CliChatResumeSource, error: unknown, - openOwner: boolean, - target: RuntimeClientTarget -): Promise { - const refusal = structuredLaunchFailure(error) - if ( - refusal?.code === 'agent_session_conflict' || - refusal?.code === 'agent_session_ownership_unknown' - ) { - const owner = refusal.owner - if (openOwner && owner) { - await activateAiVaultStructuredSession({ structuredSession: owner }, undefined, target) - return - } - toast.error( - refusal.code === 'agent_session_conflict' && - refusal.details?.reason === 'conversationHeldElsewhere' && - !owner - ? cliConversationExitGuidance(source.agent, true) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', - 'Another chat is already holding this conversation.' - ) + target: RuntimeClientTarget, + viewerSelection?: string +): Promise { + const owner = structuredLaunchFailure(error)?.owner + if (owner) { + return switchIntoOwnerLegacy(source, owner, target, viewerSelection) + } + toastCliChatResumeFailure(source, error) + return false +} + +export async function switchIntoOwnerLegacy( + source: Pick & Pick, + structuredSession: NonNullable, + target: RuntimeClientTarget, + viewerSelection?: string, + reportFailure?: (error: unknown) => void +): Promise { + // A host without the switch keeps the earlier behaviour: the chat is shown, the terminal stays. + if (!(await supportsInPlaceSwitch(target, source.agent))) { + await activateAiVaultStructuredSession( + { structuredSession }, + undefined, + target, + viewerSelection, + ...(reportFailure ? ([() => reportFailure(undefined)] as const) : ([] as const)) ) - return + return false } - // The chat's agent refused to start while the terminal may still write the conversation. - if ( - refusal?.code === 'agent_session_operation_invalid' && - refusal.details?.reason === 'conversationOpenInTerminal' - ) { - toast.error(cliConversationExitGuidance(source.agent, true)) - return + if (!(await startCliChatReturn(source, structuredSession.sessionId, target, reportFailure))) { + return false } - if (refusal?.code === 'agent_session_identity_required') { - if (refusal.details?.reason === 'transcriptNotFound') { - toast.error( - translate( - 'components.native-chat.resumeInNewChat.notSavedYet', - "{{agent}} hasn't saved this conversation yet. Try again after it replies.", - { agent: agentLabel(source.agent) } - ) - ) - return + const shown = await activateAiVaultStructuredSessionVerified( + { structuredSession }, + undefined, + target, + viewerSelection, + ...(reportFailure ? ([() => reportFailure(undefined)] as const) : ([] as const)) + ) + if (shown !== 'activated') { + return false + } + if (structuredSession.workspaceId !== source.workspaceId) { + const state = useAppStore.getState() + const scope = parseWorkspaceKey(structuredSession.workspaceId) + const folder = + scope?.type === 'folder' + ? selectFolderWorkspaces(state).find( + (workspace) => workspace.id === scope.folderWorkspaceId + ) + : undefined + const worktree = getIndexedWorktreeById( + selectWorktreesByRepo(state), + structuredSession.workspaceId + ) + const workspace = + folder?.name ?? + (worktree ? resolveWorktreeDisplayName(worktree) : structuredSession.workspaceId) + toast.success( + translate('components.tab.bar.switch.openedInWorkspace', 'Opened chat in {{workspace}}.', { + workspace + }) + ) + return false + } + { + const state = useAppStore.getState() + const tabs = state.unifiedTabsByWorktree[source.workspaceId] ?? [] + const chat = tabs.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === structuredSession.sessionId + ) + const group = state.groupsByWorktree[source.workspaceId]?.find((g) => + g.tabOrder.includes(source.tabId) + ) + if (chat && group) { + const order = group.tabOrder.filter((id) => id !== chat.id) + const index = order.indexOf(source.tabId) + 1 + if (chat.groupId === group.id) { + order.splice(index, 0, chat.id) + state.reorderUnifiedTabs(group.id, order) + } else { + state.moveUnifiedTabToGroup(chat.id, group.id, { + index, + activate: conversationSwitchViewerSelectionCurrent(viewerSelection) + }) + } } - const failure = structuredLaunchFailure(error) - if (failure) { - toast.error(agentSessionWriteFailureText(failure, 'read-history')) - return + } + return shown === 'activated' +} + +/** Explicit returns await the selected child; ordinary lazy creation remains unchanged. */ +export async function startCliChatReturn( + source: Pick, + sessionId: string, + target: RuntimeClientTarget, + reportFailure?: (error: unknown) => void +): Promise { + const switched = await callStructuredAgentSession< + { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } + >(target, 'agentSession.switchToChat', { sessionId }).catch(() => null) + if (!switched?.ok) { + if (reportFailure) { + reportFailure(switched ? Object.assign(new Error('Chat switch refused'), switched) : null) + return false } + toast.error( + conversationSwitchFailureText( + switched ? Object.assign(new Error('Chat switch refused'), switched) : null, + { + agent: source.agent, + direction: 'chat' + } + ) + ) + return false } + return true +} + +export function toastCliChatResumeFailure(source: CliChatResumeSource, error: unknown): void { toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeInChatFailed', - 'Could not resume this session in a new chat.' - ) + conversationSwitchFailureText(error, { agent: source.agent, direction: 'chat', source }) ) } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-feedback.ts new file mode 100644 index 000000000000..6286f37ebbbd --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-feedback.ts @@ -0,0 +1,88 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' +import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' +import { agentSessionWriteFailureText } from '../native-chat/agent-session-write-notice-text' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' +import type { CliChatResumeSource } from './cli-chat-resume-source' + +export async function reportCliChatResumeFailure( + source: CliChatResumeSource, + error: unknown, + openOwner: boolean, + target: RuntimeClientTarget, + viewerSelection?: string, + reportFailure?: (error: unknown) => void +): Promise { + const refusal = structuredLaunchFailure(error) + if ( + refusal?.code === 'agent_session_conflict' || + refusal?.code === 'agent_session_ownership_unknown' + ) { + const owner = refusal.owner + if (openOwner && owner) { + await (viewerSelection === undefined + ? activateAiVaultStructuredSession({ structuredSession: owner }, undefined, target) + : activateAiVaultStructuredSession( + { structuredSession: owner }, + undefined, + target, + viewerSelection, + ...(reportFailure ? ([() => reportFailure(error)] as const) : ([] as const)) + )) + return + } + if (reportFailure) { + reportFailure(error) + return + } + toast.error( + refusal.code === 'agent_session_conflict' && + refusal.details?.reason === 'conversationHeldElsewhere' && + !owner + ? cliConversationExitGuidance(source.agent, true) + : translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', + 'Another chat is already holding this conversation.' + ) + ) + return + } + if (reportFailure) { + reportFailure(error) + return + } + // The chat's agent refused to start while the terminal may still write the conversation. + if ( + refusal?.code === 'agent_session_operation_invalid' && + refusal.details?.reason === 'conversationOpenInTerminal' + ) { + toast.error(cliConversationExitGuidance(source.agent, true)) + return + } + if (refusal?.code === 'agent_session_identity_required') { + if (refusal.details?.reason === 'transcriptNotFound') { + toast.error( + translate( + 'components.native-chat.resumeInNewChat.notSavedYet', + "{{agent}} hasn't saved this conversation yet. Try again after it replies.", + { agent: agentLabel(source.agent) } + ) + ) + return + } + const failure = structuredLaunchFailure(error) + if (failure) { + toast.error(agentSessionWriteFailureText(failure, 'read-history')) + return + } + } + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatFailed', + 'Could not resume this session in a new chat.' + ) + ) +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-run.ts new file mode 100644 index 000000000000..82227b7a604b --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-ordinary-run.ts @@ -0,0 +1,192 @@ +import { useSyncExternalStore } from 'react' +import { toast } from 'sonner' +import { conversationSwitchSourceKept } from '@/lib/conversation-switch-feedback' +import { useAppStore } from '@/store' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/agent-session/structured-agent-session-provisional-tab' +import { supportsStructuredAgentSessionCliSource } from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' +import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { toHostSessionPaneKey } from '../../../../shared/terminal-surface-id' +import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' +import { reportCliChatResumeFailure } from './cli-chat-resume-ordinary-feedback' +import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' + +const pending = new Map() +const listeners = new Set<() => void>() +const changed = () => listeners.forEach((listener) => listener()) +const keyFor = (source: CliChatResumeSource) => + [ + source.executionHostId, + CLI_CONVERSATION_ADOPTING_AGENTS[source.agent], + source.providerSessionId + ].join('\0') +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function useCliChatResumePending(source: CliChatResumeSource | null): boolean { + return useSyncExternalStore( + subscribe, + () => !!source && pending.has(keyFor(source)), + () => false + ) +} + +export async function runOrdinaryCliChatResume( + source: CliChatResumeSource, + openOwner = false, + selection?: { + token?: string + reportFailure?: (error: unknown) => void + current: () => boolean + sourceCurrent: () => boolean + afterTabId: string + } +): Promise { + const key = keyFor(source) + if (pending.has(key) || source.hostUpdateRequired) { + return + } + const target = structuredAgentSessionTargetForHost(source.executionHostId) + if (!target) { + return + } + const controller = new AbortController() + const isCurrent = () => { + if (controller.signal.aborted || (selection && !selection.sourceCurrent())) { + return false + } + // The strict route already proved the captured process exited; idle is not loss of identity. + if (selection) { + return true + } + const current = resolveCliChatResumeSource(useAppStore.getState(), { + tab: { id: source.tabId, worktreeId: source.workspaceId }, + paneKey: source.paneKey + }) + return ( + !!current && + current.executionHostId === source.executionHostId && + current.providerSessionId === source.providerSessionId && + CLI_CONVERSATION_ADOPTING_AGENTS[current.agent] === + CLI_CONVERSATION_ADOPTING_AGENTS[source.agent] + ) + } + if (!isCurrent()) { + return + } + const release = () => { + if (pending.get(key) === controller) { + pending.delete(key) + changed() + } + } + pending.set(key, controller) + changed() + const stopSource = useAppStore.subscribe(() => { + if (!isCurrent()) { + controller.abort() + release() + } + }) + const timer = setTimeout( + () => controller.abort(new Error('Conversation preparation timed out.')), + 30_000 + ) + let dispatched = false + const handoff = () => { + if (dispatched) { + return true + } + if (!isCurrent()) { + return false + } + dispatched = true + stopSource() + clearTimeout(timer) + return true + } + try { + const cliSourceSupported = await waitForPromiseWithSignal( + supportsStructuredAgentSessionCliSource(target), + controller.signal + ) + if (!isCurrent()) { + return + } + const agent = await waitForPromiseWithSignal( + adoptingAgentOnHost(source.agent, source.workspaceId, source.executionHostId), + controller.signal + ) + if (!isCurrent()) { + return + } + const plan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + requestId: newAgentLaunchRequestId(), + agent, + worktreeId: source.workspaceId, + executionHostId: source.executionHostId, + resumeFrom: { + providerSessionId: source.providerSessionId, + // The host matches its own pane keys, not this client's mirrored ones. + ...(cliSourceSupported + ? { + cliSource: { + workspaceId: source.workspaceId, + paneKey: toHostSessionPaneKey(source.paneKey) + } + } + : {}) + } + }) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan, + hooks: { beforeCreate: handoff }, + deferPublication: true, + ...(selection + ? { + viewerSelection: selection.token, + canActivate: selection.current, + afterTabId: selection.afterTabId + } + : {}) + }) + if (!launch) { + return + } + const result = await waitForPromiseWithSignal(launch.settlement, controller.signal) + if (selection && result.kind === 'structured') { + toast.info(conversationSwitchSourceKept(source.agent)) + } + if (result.kind === 'failed' && !result.notified && pending.get(key) === controller) { + await reportCliChatResumeFailure( + source, + result.error, + openOwner, + target, + selection?.token, + selection?.reportFailure + ) + } + } catch (error) { + if (pending.get(key) === controller) { + await reportCliChatResumeFailure( + source, + error, + false, + target, + selection?.token, + selection?.reportFailure + ) + } + } finally { + clearTimeout(timer) + stopSource() + release() + } +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 089780cc08e9..6766b3540e2f 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -1,3 +1,4 @@ +import { conversationSwitchFailureText } from '@/lib/conversation-switch-feedback' // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { CliChatResumeSource } from './cli-chat-resume-source' @@ -91,7 +92,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime- import { useAppStore } from '@/store' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' -import { runCliChatResume } from './cli-chat-resume-run' +import { runOrdinaryCliChatResume as runCliChatResume } from './cli-chat-resume-ordinary-run' import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' import { resetStructuredAgentLaunchRegistryForTests, @@ -116,10 +117,12 @@ const SOURCE: CliChatResumeSource = { paneKey: 'tab-1:leaf', agent: 'claude', providerSessionId: 'provider-1', - executionHostId: 'local' + executionHostId: 'local', + ptyId: undefined } const tabs = () => useAppStore.getState().unifiedTabsByWorktree['wt-1'] ?? [] let main: TabsDrawnFromMain | undefined +let snapshotVersion = 0 afterEach(() => { vi.unstubAllGlobals() main?.close() @@ -142,6 +145,7 @@ function refuse(reason: 'transcriptNotFound' | 'transcriptUnreadable'): AgentSes } beforeEach(async () => { + snapshotVersion = 0 vi.clearAllMocks() resetLocalStructuredSessionVersionForTests() resetWebSessionTabsSnapshotFreshnessForTests() @@ -169,7 +173,7 @@ function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { return { worktree: 'wt-1', publicationEpoch: 'publication-test', - snapshotVersion: Date.now(), + snapshotVersion: ++snapshotVersion, activeGroupId: null, activeTabId: null, activeTabType: null, @@ -352,7 +356,7 @@ describe('CLI identity resume through the existing launch owner', () => { } }) - it('keeps the terminal and directs a refused terminal writer to Session History', async () => { + it('keeps the terminal and directs a refused terminal writer to quit in this terminal', async () => { mocks.call.mockResolvedValue({ ok: false, refusal: { @@ -365,7 +369,7 @@ describe('CLI identity resume through the existing launch owner', () => { await runCliChatResume(SOURCE, true) expect(mocks.toast).toHaveBeenCalledTimes(1) expect(mocks.toast.mock.calls[0]?.[0]).toBe( - 'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal, then open this conversation from Session History.' + 'Orca could not confirm Claude stopped in this terminal. Quit Claude here, then try this action again.' ) expect(mocks.activate).not.toHaveBeenCalled() expect(tabs()).toEqual([]) @@ -535,9 +539,7 @@ describe('CLI identity resume through the existing launch owner', () => { ).toEqual(expectedOrder) expect(useAppStore.getState().activeGroupIdByWorktree['wt-1']).toBe(other.groupId) useAppStore.getState().moveUnifiedTabToGroup(slotId, other.groupId) - applyStructuredSessionTabSnapshots([ - { ...snapshot(sessionId), snapshotVersion: Date.now() + 1 } - ]) + applyStructuredSessionTabSnapshots([snapshot(sessionId)]) expect(tabs().find((tab) => tab.entityId === sessionId)?.groupId).toBe(other.groupId) } ) @@ -640,3 +642,35 @@ describe('CLI identity resume through the existing launch owner', () => { expect(useAppStore.getState().groupsByWorktree['wt-1'][0].recentTabIds ?? []).toEqual([]) }) }) + +it.each(['preparation', 'refusal'])( + 'unified strict legacy admission reports %s through the common formatter once', + async (stage) => { + if (stage === 'preparation') { + mocks.sourceCapability.mockRejectedValue(new Error('raw provider transport text')) + } else { + mocks.call.mockResolvedValue({ ok: false, refusal: refuse('transcriptUnreadable') }) + } + await runCliChatResume(SOURCE, true, { + current: () => true, + sourceCurrent: () => true, + afterTabId: SOURCE.tabId, + reportFailure: (error) => { + mocks.toast( + conversationSwitchFailureText(error, { + agent: SOURCE.agent, + direction: 'chat', + source: SOURCE + }) + ) + } + }) + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith( + stage === 'refusal' + ? "Claude couldn't open this conversation's history. Check that its saved conversation is available, then try again." + : 'Claude could not start in chat. Check its setup and try again.' + ) + expect(tabs()).toEqual([]) + expect(mocks.activate).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 2081b10bb86a..06081cea36c3 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -1,155 +1,140 @@ -import { useSyncExternalStore } from 'react' +import { runOrdinaryCliChatResume } from './cli-chat-resume-ordinary-run' +import { + captureTerminalSurfaceIdentity, + isRetainedTerminalSurfaceIdentity +} from '../../../../shared/terminal-tab-close' +import { toast } from 'sonner' +import { conversationSwitchFailureText } from '@/lib/conversation-switch-feedback' +import { + conversationSwitchChanged, + isCliChatResumePending, + pendingCliChatResumes as pending +} from '@/lib/pending-conversation-surface-switches' import { useAppStore } from '@/store' -import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' -import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/agent-session/structured-agent-session-provisional-tab' -import { supportsStructuredAgentSessionCliSource } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' -import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' -import { toHostSessionPaneKey } from '../../../../shared/terminal-surface-id' import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' -import { reportCliChatResumeFailure } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { captureSourceTerminal } from './cli-chat-resume-terminal-capture' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { captureConversationSwitchSelection } from '@/lib/conversation-switch-state' -const pending = new Map() -const listeners = new Set<() => void>() -const changed = () => listeners.forEach((listener) => listener()) -const keyFor = (source: CliChatResumeSource) => - [ - source.executionHostId, - CLI_CONVERSATION_ADOPTING_AGENTS[source.agent], - source.providerSessionId - ].join('\0') -const subscribe = (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) -} - -export function useCliChatResumePending(source: CliChatResumeSource | null): boolean { - return useSyncExternalStore( - subscribe, - () => !!source && pending.has(keyFor(source)), - () => false - ) -} - -export async function runCliChatResume( +/** Old peers stop the addressed PTY before ordinary adoption; a kill acknowledgement is never exit proof. */ +export async function runLegacyCliChatResume( source: CliChatResumeSource, - openOwner = false + viewerSelection?: string, + reportFailure?: (error: unknown) => void ): Promise { - const key = keyFor(source) - if (pending.has(key) || source.hostUpdateRequired) { - return - } + const key = `${source.executionHostId}\0${source.agent}\0${source.providerSessionId}` const target = structuredAgentSessionTargetForHost(source.executionHostId) - if (!target) { + if (!target || source.hostUpdateRequired || isCliChatResumePending(key)) { return } + const stillSelected = captureConversationSwitchSelection(viewerSelection) + const leafId = source.paneKey.slice(source.tabId.length + 1) + const rendererIdentity = captureTerminalSurfaceIdentity( + useAppStore.getState(), + source.workspaceId, + source.tabId, + leafId + ) + const stillCaptured = () => { + const current = captureTerminalSurfaceIdentity( + useAppStore.getState(), + source.workspaceId, + source.tabId, + leafId + ) + return ( + rendererIdentity !== null && + current !== null && + isRetainedTerminalSurfaceIdentity(rendererIdentity, current) + ) + } const controller = new AbortController() const isCurrent = () => { - if (controller.signal.aborted) { - return false - } const current = resolveCliChatResumeSource(useAppStore.getState(), { tab: { id: source.tabId, worktreeId: source.workspaceId }, paneKey: source.paneKey }) return ( - !!current && - current.executionHostId === source.executionHostId && - current.providerSessionId === source.providerSessionId && - CLI_CONVERSATION_ADOPTING_AGENTS[current.agent] === - CLI_CONVERSATION_ADOPTING_AGENTS[source.agent] + current?.ptyId === source.ptyId && + current?.executionHostId === source.executionHostId && + current?.providerSessionId === source.providerSessionId && + current?.agent === source.agent ) } - if (!isCurrent()) { - return - } - const release = () => { - if (pending.get(key) === controller) { - pending.delete(key) - changed() - } - } - pending.set(key, controller) - changed() - const stopSource = useAppStore.subscribe(() => { - if (!isCurrent()) { - controller.abort() - release() - } - }) - const timer = setTimeout( - () => controller.abort(new Error('Conversation preparation timed out.')), - 30_000 - ) - let dispatched = false - const handoff = () => { - if (dispatched) { - return true - } - if (!isCurrent()) { - return false - } - dispatched = true - stopSource() - clearTimeout(timer) - return true - } + pending.set(controller, { key, source }) + conversationSwitchChanged() try { - const cliSourceSupported = await waitForPromiseWithSignal( - supportsStructuredAgentSessionCliSource(target), - controller.signal - ) - if (!isCurrent()) { + const captured = await captureSourceTerminal(source) + const ptyId = captured?.presentation?.identity.ptyId + if (!captured || !ptyId || !isCurrent()) { + throw stopUnknown() + } + if (!stillCaptured()) { return } - const agent = await waitForPromiseWithSignal( - adoptingAgentOnHost(source.agent, source.workspaceId, source.executionHostId), - controller.signal - ) - if (!isCurrent()) { + await adoptingAgentOnHost(source.agent, source.workspaceId, source.executionHostId) + if (!isCurrent() || !stillCaptured()) { return } - const plan = adoptAgentSessionLaunchVerdict({ - route: 'structured-native-chat', - requestId: newAgentLaunchRequestId(), - agent, - worktreeId: source.workspaceId, - executionHostId: source.executionHostId, - resumeFrom: { - providerSessionId: source.providerSessionId, - // The host matches its own pane keys, not this client's mirrored ones. - ...(cliSourceSupported - ? { - cliSource: { - workspaceId: source.workspaceId, - paneKey: toHostSessionPaneKey(source.paneKey) - } - } - : {}) - } + const stopped = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + postStopVerified?: boolean + remainingLivePtyIds?: string[] + }>(captured.target, 'terminal.stopExact', { + worktree: `id:${source.workspaceId}`, + expectedPtyIds: [ptyId], + targetOnly: true, + keepHistory: true + }).catch(() => { + throw stopUnknown() }) - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan, - hooks: { beforeCreate: handoff }, - deferPublication: true + if ( + !stopped || + stopped.postStopVerified !== true || + !Array.isArray(stopped.stoppedPtyIds) || + stopped.stoppedPtyIds.length !== 1 || + stopped.stoppedPtyIds[0] !== ptyId || + (stopped.remainingLivePtyIds !== undefined && + (!Array.isArray(stopped.remainingLivePtyIds) || + stopped.remainingLivePtyIds.includes(ptyId))) + ) { + throw stopUnknown() + } + const current = resolveCliChatResumeSource(useAppStore.getState(), { + tab: { id: source.tabId, worktreeId: source.workspaceId }, + paneKey: source.paneKey }) - if (!launch) { + if (current && !isCurrent()) { return } - const result = await waitForPromiseWithSignal(launch.settlement, controller.signal) - if (result.kind === 'failed' && !result.notified && pending.get(key) === controller) { - await reportCliChatResumeFailure(source, result.error, openOwner, target) + if (!stillCaptured()) { + return } + await runOrdinaryCliChatResume(source, true, { + token: viewerSelection, + reportFailure, + current: stillSelected, + sourceCurrent: stillCaptured, + afterTabId: source.tabId + }) } catch (error) { - if (pending.get(key) === controller) { - await reportCliChatResumeFailure(source, error, false, target) + // A stopped shell can cease being busy or disappear; that never turns proven Stop into a failure. + if (reportFailure) { + reportFailure(error) + } else { + toast.error( + conversationSwitchFailureText(error, { agent: source.agent, direction: 'chat', source }) + ) } } finally { - clearTimeout(timer) - stopSource() - release() + pending.delete(controller) + conversationSwitchChanged() } } + +function stopUnknown() { + return agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }) +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index 42e407e92b16..5b250e042cc7 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -1,6 +1,10 @@ +import { useAppStore } from '@/store' +import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { collectAgentPaneConversations, + panePtyId, resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -8,7 +12,7 @@ import { adoptsCliConversations, type CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' -import { resolvePaneResumeInNewChatOffer } from './tab-session-history-switch' +import { resolvePaneSwitchToChatOffer } from './tab-session-history-switch' export type CliChatResumeSource = { tabId: string @@ -17,27 +21,32 @@ export type CliChatResumeSource = { agent: CliConversationRowAgent providerSessionId: string executionHostId: ExecutionHostId + /** The pane's PTY when this was resolved; only that execution may be retired. */ + ptyId: string | undefined /** The host predates chat resume: show the action disabled and ask for an update. */ hostUpdateRequired?: true } +/** The pane's last reported conversation, or the one the host launched in its current PTY. */ export function resolveCliChatResumeSource( state: AppState, args: { tab: { id: string; worktreeId: string }; paneKey?: string } ): CliChatResumeSource | null { + const paneConversation = (paneKey: string) => + resolvePaneLastAgentConversation( + state, + { worktreeId: args.tab.worktreeId, paneKey }, + adoptsCliConversations + ) const conversation = args.paneKey - ? resolvePaneLastAgentConversation( - state, - { worktreeId: args.tab.worktreeId, paneKey: args.paneKey }, - adoptsCliConversations - ) + ? paneConversation(args.paneKey) : collectAgentPaneConversations(state, adoptsCliConversations, { runningAgentsOnly: true, includeLastReported: true, tab: { worktreeId: args.tab.worktreeId, tabId: args.tab.id } }).find((candidate) => candidate.tabId === args.tab.id) const offer = conversation - ? resolvePaneResumeInNewChatOffer(state, { + ? resolvePaneSwitchToChatOffer(state, { worktreeId: conversation.worktreeId, paneKey: conversation.paneKey }) @@ -57,6 +66,16 @@ export function resolveCliChatResumeSource( agent: conversation.agent, providerSessionId: conversation.providerSession.id, executionHostId: conversation.executionHostId, + ptyId: panePtyId(state, conversation.paneKey), ...(offer === 'host-update-required' ? { hostUpdateRequired: true as const } : {}) } } + +/** Capability settlement must re-render readers even when pane state did not change. */ +export function useCliChatResumeCapability( + worktreeId: string, + agent = 'codex' +): boolean | undefined { + const host = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) + return useInPlaceSwitchCapability(host, agent) +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts new file mode 100644 index 000000000000..76ee70a5164c --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts @@ -0,0 +1,108 @@ +import { useAppStore } from '@/store' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import type { CliChatResumeSource } from './cli-chat-resume-source' +import type { RuntimeTerminalResolvePane } from '../../../../shared/runtime-types' +import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toHostSessionPaneKey, + toHostSessionTabId +} from '../../../../shared/terminal-surface-id' + +import { parseHostAuthorityKey } from '../../../../shared/host-authority' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + captureTerminalSurfaceIdentity, + isRetainedTerminalSurfaceIdentity +} from '../../../../shared/terminal-tab-close' + +/** Capture the existing host handle and incarnation before starting the destination. */ +export async function captureSourceTerminal(source: CliChatResumeSource) { + const owner = source.ptyId ? getRemoteRuntimePtyOwner(source.ptyId) : null + if (isRemoteRuntimePtyId(source.ptyId) && !owner) { + return null + } + if (!owner && source.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return null + } + const leafId = source.paneKey.slice(source.tabId.length + 1) + if (source.paneKey !== `${source.tabId}:${leafId}`) { + return null + } + const state = useAppStore.getState() + const rendererHost = owner ? getKnownExecutionHostIdForWorktree(state, source.workspaceId) : null + const renderer = captureTerminalSurfaceIdentity(state, source.workspaceId, source.tabId, leafId) + const sourceTarget = structuredAgentSessionTargetForHost(source.executionHostId) + if ( + owner && + (sourceTarget?.kind !== 'environment' || + sourceTarget.environmentId !== owner.target.environmentId) + ) { + return null + } + const target = owner?.target ?? { kind: 'local' as const } + try { + const { terminal } = await callRuntimeRpc<{ terminal: RuntimeTerminalResolvePane }>( + target, + 'terminal.resolvePane', + { + paneKey: owner ? toHostSessionPaneKey(source.paneKey) : source.paneKey, + worktreeId: source.workspaceId + } + ) + const original = terminal.presentation?.identity + const authority = parseHostAuthorityKey(source.executionHostId) + if ( + (!owner && source.ptyId === undefined && !renderer?.hasLeaf) || + !original || + !terminal.incarnationId || + original.worktreeId !== source.workspaceId || + original.tabId !== + (owner + ? toHostSessionTabId(source.tabId).split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + : source.tabId) || + original.leafId !== leafId || + (terminal.executionHostId != null && terminal.executionHostId !== authority?.at) || + original.ptyId !== terminal.ptyId || + original.incarnationId !== terminal.incarnationId + ) { + return null + } + const currentState = useAppStore.getState() + const current = captureTerminalSurfaceIdentity( + currentState, + source.workspaceId, + source.tabId, + leafId + ) + const currentHost = owner + ? getKnownExecutionHostIdForWorktree(currentState, source.workspaceId) + : null + const matches = owner + ? terminal.handle === owner.terminal && + currentHost === rendererHost && + (currentHost === null || currentHost === source.executionHostId) && + (renderer === null + ? current === null + : isRetainedTerminalSurfaceIdentity(renderer, current)) && + (renderer === null || + ((renderer.ptyId === null || renderer.ptyId === source.ptyId) && + (!renderer.hasLeaf || original.hasLeaf) && + (renderer.incarnationId === null || renderer.incarnationId === original.incarnationId))) + : (source.ptyId === undefined || terminal.ptyId === source.ptyId) && + isRetainedTerminalSurfaceIdentity(original, renderer) + return matches + ? { + target, + handle: owner?.terminal ?? terminal.handle, + incarnationId: terminal.incarnationId, + presentation: terminal.presentation + } + : null + } catch { + return null + } +} diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts index aa84f9e13ad9..e03f6de0683d 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts @@ -1,3 +1,4 @@ +import { conversationSwitchFailureText } from '@/lib/conversation-switch-feedback' import { beforeEach, expect, it, vi } from 'vitest' import { runNativeChatCliFork } from './native-chat-cli-fork-run' import type * as LaunchFeedback from '../right-sidebar/ai-vault-session-launch-feedback' @@ -124,13 +125,13 @@ it('lets the destination finish after dispatch and passes no source resume ident closeSource() return { tabId: null, runtimeLaunch: Promise.resolve({ status: 'created' }) } }) - await runNativeChatCliFork(args) + await runNativeChatCliFork({ ...args, queuedMessage: 'Claude copy opens in a new terminal.' }) expect(mocks.launch).toHaveBeenCalledWith({ agent: 'claude', worktreeId: 'workspace-1', command: 'claude --resume source --fork-session' }) - expect(mocks.notify).toHaveBeenCalledWith('claude') + expect(mocks.notify).toHaveBeenCalledWith('claude', 'Claude copy opens in a new terminal.') expect(mocks.error).not.toHaveBeenCalled() }) @@ -202,3 +203,30 @@ it.each([ expect(mocks.listeners.size).toBe(0) } ) + +it.each(['prepare', 'launch'])( + 'unified old Continue sanitizes its %s failure once', + async (stage) => { + mocks.prepare.mockResolvedValue(prepared) + if (stage === 'prepare') { + mocks.prepare.mockRejectedValue(new Error('raw provider secret')) + } else { + mocks.launch.mockReturnValue({ + tabId: null, + runtimeLaunch: Promise.resolve({ status: 'failed', message: 'raw provider secret' }) + }) + } + await runNativeChatCliFork({ + ...args, + describeFailure: (message) => + conversationSwitchFailureText(new Error(message), { + agent: args.agent, + direction: 'terminal' + }) + }) + expect(mocks.error).toHaveBeenCalledExactlyOnceWith( + 'Claude could not start in a terminal. Check its setup and try again.' + ) + expect(mocks.notify).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts index 921c0223e3ef..25c906b0ba76 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts @@ -51,6 +51,9 @@ export async function runNativeChatCliFork(args: { subject: NativeChatCliForkSubject tab: { id: string; worktreeId: string } agent: AiVaultAgent + viewerSelection?: string + queuedMessage?: string + describeFailure?: (message: string) => string onComplete?: () => void commandOverrides?: Partial> }): Promise { @@ -143,15 +146,21 @@ export async function runNativeChatCliFork(args: { } stop() await launchAiVaultSessionAndReveal( - { agent: target.agent, worktreeId: subject.workspaceId, ...startup }, - describeAiVaultCliForkFailure + { + agent: target.agent, + worktreeId: subject.workspaceId, + ...startup, + ...(args.viewerSelection ? { viewerSelection: args.viewerSelection } : {}) + }, + args.describeFailure ?? describeAiVaultCliForkFailure, + args.queuedMessage ) } catch (error) { if (pending.get(key) === controller) { notifyAiVaultSessionPreparationFailure( new Error(readIpcErrorMessage(error)), args.agent, - describeAiVaultCliForkFailure + args.describeFailure ?? describeAiVaultCliForkFailure ) } } finally { diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts new file mode 100644 index 000000000000..7c7b09740b21 --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts @@ -0,0 +1,94 @@ +import type { StructuredAgentSessionReadSnapshot } from '@/components/native-chat/structured-agent-session-read-owner' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const live: { held?: StructuredAgentSessionReadSnapshot; view: string } = { view: 'worktrees' } + return { + supported: true, + live, + call: vi.fn(), + launch: vi.fn((_args: unknown): { tabId: string | null; runtimeLaunch?: Promise } => ({ + tabId: 'terminal-tab' + })), + closeUnifiedTab: vi.fn(), + toast: vi.fn() + } +}) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +vi.mock('@/store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: () => ({ + settings: {}, + closeUnifiedTab: mocks.closeUnifiedTab, + activeView: mocks.live.view + }) + } +})) +vi.mock('@/runtime/local-runtime-capabilities', () => ({ + subscribeLocalRuntimeCapabilityObservation: () => () => {}, + readLocalRuntimeCapabilitiesOrUnknown: () => [], + ensureLocalRuntimeCapabilities: async () => + mocks.supported + ? ['agent-session.structured.switch.v1:codex', 'agent-session.conversation-stop.v1'] + : [] +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: async () => false +})) +vi.mock('@/components/native-chat/structured-agent-session-read-owner-registry', () => ({ + findStructuredAgentSessionReadOwner: () => + mocks.live.held ? { getSnapshot: () => mocks.live.held } : undefined +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: async () => mocks.supported, + callStructuredAgentSession: mocks.call +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: () => 'local' })) +vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch })) +vi.mock('@/lib/ai-vault-resume-command', () => ({ + buildAiVaultResumeStartupForWorktree: () => ({ + command: 'codex resume thread-c', + env: { CODEX_HOME: '/accounts/a/home' }, + providerSession: { key: 'session_id', id: 'thread-c' } + }) +})) + +import { runLegacyNativeChatCliSwitch as runNativeChatCliSwitch } from './native-chat-cli-switch-run' + +const SWITCH = { + sessionId: 'chat-1', + workspaceId: 'wt-1', + executionHostId: 'local', + agent: 'codex' as const, + chatTabId: 'chat-tab' +} +const SOURCE = { + agent: 'codex', + sourceAgent: 'codex', + sessionId: 'thread-c', + filePath: '/accounts/a/home/sessions/rollout-thread-c.jsonl', + cwd: '/repo', + codexHome: '/accounts/a/home', + executionHostPlatform: 'darwin' +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.supported = true + mocks.live.held = undefined + mocks.live.view = 'worktrees' + mocks.call.mockResolvedValue({ source: SOURCE }) +}) + +it('preserves readable recovery and never launches from an old Close acknowledgement', async () => { + mocks.supported = false + await runNativeChatCliSwitch(SWITCH) + expect(mocks.call.mock.calls.map((call) => call[1])).toEqual([ + 'agentSession.close', + 'agentSession.reveal' + ]) + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts new file mode 100644 index 000000000000..55b906f2adfa --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts @@ -0,0 +1,115 @@ +import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' +import { runningStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-live-turn' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { ensureLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + conversationSwitchChanged, + pendingNativeChatCliSwitches as pending +} from '@/lib/pending-conversation-surface-switches' +import { conversationSwitchFailureText } from '@/lib/conversation-switch-feedback' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +const changed = conversationSwitchChanged +/** Moves a chat's conversation into a terminal on the same id: the host proves the chat stopped, the + * terminal opens in the chat's slot, then the chat tab closes. `chatTabId` null opens a new tab. */ +export async function runLegacyNativeChatCliSwitch(args: { + sessionId: string + workspaceId: string + executionHostId: string + agent: AiVaultAgent + chatTabId: string | null + onComplete?: () => void + viewerSelection?: string + reportFailure?: (error: unknown) => void +}): Promise { + const key = `${args.executionHostId}\0${args.sessionId}\0legacy` + const target = structuredAgentSessionTargetForHost(args.executionHostId) + const hostId = parseExecutionHostId(args.executionHostId)?.id + if (pending.has(key) || !target || !hostId) { + return + } + pending.set(key, args) + try { + changed() + if ( + getExecutionHostIdForWorktree(useAppStore.getState(), args.workspaceId) !== + args.executionHostId + ) { + throw new Error('source unavailable') + } + const supports = async (capability: string) => + target.kind === 'local' + ? (await ensureLocalRuntimeCapabilities())?.includes(capability) === true + : runtimeEnvironmentSupportsCapability(target.environmentId, capability) + const held = findStructuredAgentSessionReadOwner(args.sessionId, target)?.getSnapshot() + if (held?.current && held.state.fence !== null) { + const conversationStop = await supports(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) + const turnId = runningStructuredAgentSessionTurnId(held.state) + if (conversationStop || turnId) { + const fields = conversationStop ? {} : { turnId } + const stopped = await callStructuredAgentSession<{ ok: boolean }>( + target, + 'agentSession.cancel', + { + envelope: { + sessionId: args.sessionId, + clientOperationId: createBrowserUuid(), + expectedRuntimeFence: held.state.fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: args.sessionId, + fields + }) + }, + ...fields + } + ) + if (!stopped.ok) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'ownerUnproven' + }) + } + } + } + // Ordinary old Close has no positive exit receipt; keep readable recovery without launching. + try { + await callStructuredAgentSession(target, 'agentSession.close', { sessionId: args.sessionId }) + } finally { + await callStructuredAgentSession(target, 'agentSession.reveal', { + sessionId: args.sessionId + }).catch(() => {}) + } + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }) + } catch (error) { + if (args.reportFailure) { + args.reportFailure(error) + } else { + toast.error( + conversationSwitchFailureText(error, { agent: args.agent, direction: 'terminal' }) + ) + } + } finally { + pending.delete(key) + try { + useAppStore.setState((state) => ({ + agentConversationsByPaneKey: state.agentConversationsByPaneKey + })) + } finally { + try { + changed() + } finally { + args.onComplete?.() + } + } + } +} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-fixtures.test-fixture.ts b/src/renderer/src/components/tab-bar/tab-session-history-fixtures.test-fixture.ts new file mode 100644 index 000000000000..425a67839d6b --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-session-history-fixtures.test-fixture.ts @@ -0,0 +1,90 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { makeRepo, makeTerminalTab, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { TabSessionHistorySubject } from './tab-session-history-switch' + +export const WORKTREE_ID = 'repo-1::/repo/wt' + +export function row(overrides: Partial = {}): AiVaultSession { + return { + id: 'row-1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'claude-session-1', + title: 'Fix the build', + cwd: '/repo/wt', + branch: 'main', + model: null, + filePath: '/home/.claude/projects/repo/claude-session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-08T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [{ role: 'user', text: 'Fix the build', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume claude-session-1', + subagent: null, + ...overrides + } +} + +const SIBLING_ID = 'repo-1::/repo/sibling' + +export function makeState( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] = {}, + repo: Partial> = {} +): AppState { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + agentStatusByPaneKey, + activeRepoId: 'repo-1', + activeWorktreeId: WORKTREE_ID, + repos: [{ ...makeRepo(), ...repo }], + tabsByWorktree: { + [WORKTREE_ID]: [makeTerminalTab('term-1', WORKTREE_ID, 'claude')] + }, + worktreesByRepo: { + 'repo-1': [ + makeWorktree(WORKTREE_ID, 'wt', { path: '/repo/wt' }), + makeWorktree(SIBLING_ID, 'sibling', { path: '/repo/sibling' }) + ] + } + }) + return useAppStore.getState() +} + +export const PANEL_REQUEST = { + scopePaths: ['/repo/wt'], + executionHostScope: 'local', + sessionLimit: 250 +} as const + +export const liveClaudeEntry = { + 'term-1:leaf-1': makeAgentStatusEntry({ + agentType: 'claude', + paneKey: 'term-1:leaf-1', + tabId: 'term-1', + worktreeId: WORKTREE_ID, + providerSession: { key: 'session_id', id: 'claude-session-1' } + }) +} + +export const chatSubject: TabSessionHistorySubject = { + kind: 'chat', + sessionId: 'orca-chat-1', + workspaceId: WORKTREE_ID, + request: PANEL_REQUEST +} + +export const cliSubject: TabSessionHistorySubject = { + kind: 'cli', + agent: 'claude', + providerSessionId: 'claude-session-1', + workspaceId: WORKTREE_ID, + request: PANEL_REQUEST +} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 85423ccb7699..f3592184212d 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -1,54 +1,60 @@ -import { makeTabSessionHistoryRow as row } from './tab-session-history-test-fixture' -import { applyProviderSessionOnlyUpdate } from '@/hooks/ipc-events/agent-status-partial-updates' +import { + WORKTREE_ID, + row, + makeState, + PANEL_REQUEST, + liveClaudeEntry, + chatSubject, + cliSubject +} from './tab-session-history-fixtures.test-fixture' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { makeRepo, makeTerminalTab, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { makeTerminalTab } from '../worktree-jump-palette-test-fixtures' import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness' import { collectAiVaultTitleRequests } from '@/lib/ai-vault-tab-title-requests' import { resolveCliChatResumeSource } from './cli-chat-resume-source' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import { applyProviderSessionOnlyUpdate } from '@/hooks/ipc-events/agent-status-partial-updates' import type { AiVaultListArgs, AiVaultSession } from '../../../../shared/ai-vault-types' import { getDefaultSettings } from '../../../../shared/constants' -import { - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' -import { - claimAiVaultForcedRescan, - resetAiVaultForcedRescanThrottleForTest -} from '../right-sidebar/ai-vault-session-refresh' -import { - cacheAiVaultSessionList, - readCachedAiVaultSessionList -} from '../right-sidebar/ai-vault-session-list-request' +import * as protocol from '../../../../shared/protocol-version' +import * as sessionRefresh from '../right-sidebar/ai-vault-session-refresh' +import * as listCache from '../right-sidebar/ai-vault-session-list-request' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' import { canPaneOfferResumeInNewChat, findTabSessionHistoryRow, lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, resolveNativeChatCliForkIdentity, - resolveTabSessionSwitch, - type TabSessionHistorySubject + resolveTabSessionSwitch } from './tab-session-history-switch' +import type * as LaunchPlan from '@/lib/agent-session-launch-plan' + const mocks = vi.hoisted(() => { const state: { resumeState: { blocked: boolean; worktreeId: string | null } platform: string } = { resumeState: { blocked: false, worktreeId: 'repo-1::/repo/wt' }, platform: 'darwin' } - return { ...state, resumeInChat: vi.fn() } + return { + ...state, + switchOwner: vi.fn(async () => true), + resumeInChat: vi.fn(), + inPlaceSwitch: false, + remoteFeasible: false + } }) +vi.mock('@/lib/conversation-surface-switch', () => ({ runConversationSwitch: mocks.switchOwner })) + // Staged host answers: its capabilities arrive over IPC at boot, and the platform from preload. vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), readLocalRuntimeCapabilitiesOrUnknown: () => [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + protocol.STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + protocol.STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ] })) vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ @@ -60,6 +66,19 @@ vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) +vi.mock('@/lib/in-place-switch-support', () => ({ + inPlaceSwitchSupportedNow: () => mocks.inPlaceSwitch +})) +// A paired host's chat support arrives in its runtime status; staged here as the route's answer. +vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + structuredAgentSessionLaunchFeasible: ( + ...args: Parameters + ) => mocks.remoteFeasible || actual.structuredAgentSessionLaunchFeasible(...args) + } +}) // Parity with the panel's real composition is pinned in tab-session-history-switch.parity.test.ts. vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-workspace', () => ({ @@ -69,66 +88,8 @@ vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-workspace', () => ({ }) })) -const WORKTREE_ID = 'repo-1::/repo/wt' - -const SIBLING_ID = 'repo-1::/repo/sibling' - -function makeState( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] = {}, - repo: Partial> = {} -): AppState { - useAppStore.setState(useAppStore.getInitialState(), true) - useAppStore.setState({ - agentStatusByPaneKey, - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE_ID, - repos: [{ ...makeRepo(), ...repo }], - tabsByWorktree: { - [WORKTREE_ID]: [makeTerminalTab('term-1', WORKTREE_ID, 'claude')] - }, - worktreesByRepo: { - 'repo-1': [ - makeWorktree(WORKTREE_ID, 'wt', { path: '/repo/wt' }), - makeWorktree(SIBLING_ID, 'sibling', { path: '/repo/sibling' }) - ] - } - }) - return useAppStore.getState() -} - -const PANEL_REQUEST = { - scopePaths: ['/repo/wt'], - executionHostScope: 'local', - sessionLimit: 250 -} as const - -const liveClaudeEntry = { - 'term-1:leaf-1': makeAgentStatusEntry({ - agentType: 'claude', - paneKey: 'term-1:leaf-1', - tabId: 'term-1', - worktreeId: WORKTREE_ID, - providerSession: { key: 'session_id', id: 'claude-session-1' } - }) -} - -const chatSubject: TabSessionHistorySubject = { - kind: 'chat', - sessionId: 'orca-chat-1', - workspaceId: WORKTREE_ID, - request: PANEL_REQUEST -} - -const cliSubject: TabSessionHistorySubject = { - kind: 'cli', - agent: 'claude', - providerSessionId: 'claude-session-1', - workspaceId: WORKTREE_ID, - request: PANEL_REQUEST -} - beforeEach(() => { - resetAiVaultForcedRescanThrottleForTest() + sessionRefresh.resetAiVaultForcedRescanThrottleForTest() mocks.resumeState = { blocked: false, worktreeId: WORKTREE_ID } mocks.platform = 'darwin' mocks.resumeInChat.mockReset() @@ -219,10 +180,12 @@ describe('resolveTabSessionHistorySubject', () => { liveTabCount: 1, liveLeafCount: 1, capabilities: [ - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - ...(supported ? [STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY] : []) + protocol.STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + protocol.STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + protocol.STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + ...(supported + ? [protocol.STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY] + : []) ] } } @@ -586,7 +549,7 @@ describe('one pane of a terminal tab', () => { tab: { id: 'term-1', worktreeId: WORKTREE_ID }, paneKey: pane }) - ).toMatchObject({ agent: 'codex', providerSessionId: 'thread-c' }) + ).toMatchObject({ agent: 'codex', providerSessionId: 'thread-c', ptyId: 'pty-1' }) expect(paneGate(state, `term-1:${L2}`)).toBe(false) useAppStore .getState() @@ -739,20 +702,20 @@ describe('lookupTabSessionHistoryRow', () => { force: undefined, requestToken: 't' }) - expect(readCachedAiVaultSessionList(PANEL_REQUEST)).toBeNull() + expect(listCache.readCachedAiVaultSessionList(PANEL_REQUEST)).toBeNull() }) it("leaves the panel's cached list as it was, even after a forced rescan", async () => { - cacheAiVaultSessionList(PANEL_REQUEST, listResult([row({ id: 'panel-row' })]), { + listCache.cacheAiVaultSessionList(PANEL_REQUEST, listResult([row({ id: 'panel-row' })]), { replaceHostEntries: false }) const listSessions = vi.fn(async (_args: AiVaultListArgs) => listResult([emptyRow])) await lookupTabSessionHistoryRow(cliSubject, listSessions, lookupOptions) expect(listSessions).toHaveBeenLastCalledWith(expect.objectContaining({ force: true })) - expect(readCachedAiVaultSessionList(PANEL_REQUEST)?.sessions.map((s) => s.id)).toEqual([ - 'panel-row' - ]) + expect( + listCache.readCachedAiVaultSessionList(PANEL_REQUEST)?.sessions.map((s) => s.id) + ).toEqual(['panel-row']) }) it('rescans once on a miss, from the shared forced-rescan budget', async () => { @@ -793,7 +756,7 @@ describe('lookupTabSessionHistoryRow', () => { isCancelled: () => !menuOpen }) expect(listSessions).toHaveBeenCalledTimes(1) - expect(claimAiVaultForcedRescan()).toBe(true) + expect(sessionRefresh.claimAiVaultForcedRescan()).toBe(true) }) it('forces no scan on a remote Orca server', async () => { @@ -816,11 +779,11 @@ describe('lookupTabSessionHistoryRow', () => { lookupTabSessionHistoryRow(cliSubject, listSessions, lookupOptions) ).resolves.toMatchObject({ structuredSession: { sessionId: 'orca-chat-1' } }) expect(listSessions).toHaveBeenCalledTimes(1) - expect(claimAiVaultForcedRescan()).toBe(true) + expect(sessionRefresh.claimAiVaultForcedRescan()).toBe(true) }) it('forces a rescan for a click even while the budget is spent, and takes the budget', async () => { - expect(claimAiVaultForcedRescan()).toBe(true) + expect(sessionRefresh.claimAiVaultForcedRescan()).toBe(true) const listSessions = vi .fn() .mockResolvedValueOnce(listResult([emptyRow])) @@ -830,13 +793,13 @@ describe('lookupTabSessionHistoryRow', () => { lookupTabSessionHistoryRow(cliSubject, listSessions, clickOptions) ).resolves.toMatchObject({ messageCount: 2 }) expect(listSessions).toHaveBeenLastCalledWith(expect.objectContaining({ force: true })) - resetAiVaultForcedRescanThrottleForTest() + sessionRefresh.resetAiVaultForcedRescanThrottleForTest() await lookupTabSessionHistoryRow( cliSubject, vi.fn().mockResolvedValue(listResult([])), clickOptions ) - expect(claimAiVaultForcedRescan()).toBe(false) + expect(sessionRefresh.claimAiVaultForcedRescan()).toBe(false) }) it('reports no answer when the lookup is cancelled', async () => { @@ -845,6 +808,6 @@ describe('lookupTabSessionHistoryRow', () => { lookupTabSessionHistoryRow(cliSubject, listSessions, lookupOptions) ).resolves.toBeUndefined() expect(listSessions).toHaveBeenCalledTimes(1) - expect(readCachedAiVaultSessionList(PANEL_REQUEST)).toBeNull() + expect(listCache.readCachedAiVaultSessionList(PANEL_REQUEST)).toBeNull() }) }) diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 4c6a79d55e46..55698d4ff0c4 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -83,7 +83,26 @@ export function resolvePaneResumeInNewChatOffer( state: AppState, pane: { worktreeId: string; paneKey: string } ): PaneResumeInNewChatOffer { - const conversation = resolvePaneLastAgentConversation(state, pane, adoptsCliConversations) + return conversationResumeOffer( + state, + resolvePaneAgentConversation(state, pane, adoptsCliConversations) + ) +} + +export function resolvePaneSwitchToChatOffer( + state: AppState, + pane: { worktreeId: string; paneKey: string } +): PaneResumeInNewChatOffer { + return conversationResumeOffer( + state, + resolvePaneLastAgentConversation(state, pane, adoptsCliConversations) + ) +} + +function conversationResumeOffer( + state: AppState, + conversation: ReturnType +): PaneResumeInNewChatOffer { if (!conversation) { return null } diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 41996d9dc17d..2c7e506f0cf5 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -59,6 +59,17 @@ vi.mock('@/components/ui/tooltip', () => ({ } })) +// The tab card turns into a hover card when it carries the switch action; render it like the tooltip. +vi.mock('@/components/ui/hover-card', async () => { + const tooltip = await import('@/components/ui/tooltip') + return { + HoverCard: tooltip.Tooltip, + HoverCardTrigger: tooltip.TooltipTrigger, + HoverCardContent: tooltip.TooltipContent + } +}) +vi.mock('./TabSessionSwitchHoverAction', () => ({ TabSessionSwitchHoverAction: () => null })) + vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuContent: () => null, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts index 1a78071df886..20d410d9b453 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useLayoutEffect, useMemo, useRef } from 'react' import { clearClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state' import { noteUserWorkspaceSwitch } from '@/lib/workspace-switch-user-act' @@ -70,6 +71,10 @@ export function useTabBarItemActions(source: TabBarItemActionSource): TabBarItem noteUserWorkspaceSwitch(useAppStore.getState().activeWorktreeId) activate() } + const navigate = (action: () => void): void => { + noteViewerNavigation() + action() + } const actions: TabBarItemActions = { activateTerminal: (tabId) => activateRealTab(() => latest.current.props.onActivate(tabId)), activateFile: (fileId) => @@ -78,14 +83,14 @@ export function useTabBarItemActions(source: TabBarItemActionSource): TabBarItem activateRealTab(() => latest.current.props.onActivateBrowserTab?.(tabId)), activateAgentSession: (tabId) => activateRealTab(() => latest.current.props.onActivateAgentSession?.(tabId)), - close: (tabId) => latest.current.props.onClose(tabId), - closeFile: (fileId) => latest.current.props.onCloseFile?.(fileId), - closeBrowserTab: (tabId) => latest.current.props.onCloseBrowserTab?.(tabId), - closeOthers: (tabId) => latest.current.props.onCloseOthers(tabId), - closeToRight: (tabId) => latest.current.props.onCloseToRight(tabId), - closeToLeft: (tabId) => latest.current.props.onCloseToLeft(tabId), - closeSplitPane: () => latest.current.props.onCloseSplitPane?.(), - closeAllFiles: () => latest.current.props.onCloseAllFiles?.(), + close: (tabId) => navigate(() => latest.current.props.onClose(tabId)), + closeFile: (fileId) => navigate(() => latest.current.props.onCloseFile?.(fileId)), + closeBrowserTab: (tabId) => navigate(() => latest.current.props.onCloseBrowserTab?.(tabId)), + closeOthers: (tabId) => navigate(() => latest.current.props.onCloseOthers(tabId)), + closeToRight: (tabId) => navigate(() => latest.current.props.onCloseToRight(tabId)), + closeToLeft: (tabId) => navigate(() => latest.current.props.onCloseToLeft(tabId)), + closeSplitPane: () => navigate(() => latest.current.props.onCloseSplitPane?.()), + closeAllFiles: () => navigate(() => latest.current.props.onCloseAllFiles?.()), setCustomTitle: (tabId, title) => latest.current.props.onSetCustomTitle(tabId, title), setTabColor: (tabId, color) => latest.current.props.onSetTabColor(tabId, color), togglePaneExpand: (tabId) => latest.current.props.onTogglePaneExpand(tabId), @@ -94,7 +99,7 @@ export function useTabBarItemActions(source: TabBarItemActionSource): TabBarItem makePreviewFilePermanent: (fileId, tabId) => latest.current.props.onMakePreviewFilePermanent?.(fileId, tabId), togglePinned: (item) => latest.current.togglePinned(item), - toggleViewMode: (tabId) => latest.current.toggleTabViewMode(tabId) + toggleViewMode: (tabId) => navigate(() => latest.current.toggleTabViewMode(tabId)) } return ORCA_LAYOUT_OWNER_IS_MAIN ? { ...actions, ...withLayoutOwnerTabProps(actions, () => latest.current.props.worktreeId) } diff --git a/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts b/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts index ca7f1e27c8e3..ca1ff0baddf6 100644 --- a/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts +++ b/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { RefObject } from 'react' import type { DragEndEvent } from '@dnd-kit/core' import { useAppStore } from '../../store' @@ -63,6 +64,7 @@ export function commitTabDragDrop({ finishDrag(true) return } + noteViewerNavigation() const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: paneColumnSplit.groupId, splitDirection: paneColumnSplit.zone @@ -158,6 +160,7 @@ export function commitTabDragDrop({ finishDrag(true) return } + noteViewerNavigation() const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: overData.groupId, index @@ -194,6 +197,7 @@ export function commitTabDragDrop({ finishDrag(true) return } + noteViewerNavigation() const moved = dropUnifiedTab(activeData.unifiedTabId, { groupId: overData.groupId }) diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts index ae33f4c836c2..fbdd21271667 100644 --- a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' // Main frames/reconnect snapshots draw moves and select them unless selection changed; divider ratios persist on release. import { useAppStore } from '@/store' @@ -27,6 +28,9 @@ export function dropTabThroughLayoutOwner(worktreeId: string, drop: TabDrop): bo if (!command) { return false } + if (drop.kind !== 'reorder') { + noteViewerNavigation() + } const forget = expectSelectionAfterMove(worktreeId, drop) const sent = sendLayoutCommandToOwner(worktreeId, command, { // Forget after the answering frame; without a stream, retain selection intent for the reconnect snapshot. diff --git a/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts b/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts index 85ded5f46800..26e05bedd757 100644 --- a/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts +++ b/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts @@ -27,7 +27,7 @@ vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => ).runtimeLayoutClientWithTestFrames(await importOriginal()) ) // The test opens its own stream: boot's document-long one would outlive the test's transport. -vi.mock('@/runtime/workspace-layout-frame-sync', async (importOriginal) => ({ +vi.mock('@/runtime/workspace-layout-frame-startup', async (importOriginal) => ({ ...(await importOriginal()), showStartupLayoutFrames: () => {} })) diff --git a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts index 517d2d8860ba..ec3cda39716c 100644 --- a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' @@ -36,6 +37,7 @@ export function useTabGroupActivationCommands({ if (!item) { return } + noteViewerNavigation() focusGroup(worktreeId, groupId) activateTab(item.id) const runtimeEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin( @@ -94,6 +96,7 @@ export function useTabGroupActivationCommands({ if (!item) { return } + noteViewerNavigation() focusGroup(worktreeId, groupId) activateTab(item.id) if (item.contentType === 'simulator') { @@ -115,6 +118,7 @@ export function useTabGroupActivationCommands({ if (!item) { return } + noteViewerNavigation() focusGroup(worktreeId, groupId) activateTab(item.id) const runtimeEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin( @@ -139,6 +143,7 @@ export function useTabGroupActivationCommands({ const activateAgentSession = useCallback( (tabId: string) => { + noteViewerNavigation() activateStructuredAgentSessionTab({ worktreeId, tabId }) }, [worktreeId] diff --git a/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts index 95c674cf9e8c..71d4cec4853d 100644 --- a/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { toast } from 'sonner' import { useAppStore } from '../../store' import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface' @@ -40,12 +41,14 @@ export function useTabGroupCreationCommands({ return { newBrowserTab: () => { + noteViewerNavigation() void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => { toast.error(error instanceof Error ? error.message : String(error)) }) }, newSimulatorTab: worktreeState.mobileEmulatorEnabled ? () => { + noteViewerNavigation() if (getSimulatorTabForWorktree(worktreeId)) { void ensureSimulatorTab(worktreeId, { surfacePane: true }) return @@ -59,9 +62,11 @@ export function useTabGroupCreationCommands({ } : undefined, openEntry: async (args: TabCreateEntryArgs) => { + noteViewerNavigation() await openTabBarEntry(args) }, duplicateBrowserTab: (browserTabId: string, sourceUnifiedTabId: string) => { + noteViewerNavigation() void (async () => { const state = useAppStore.getState() const tabs = state.browserTabsByWorktree[worktreeId] ?? [] @@ -110,12 +115,15 @@ export function useTabGroupCreationCommands({ }, // Why: target the owning group explicitly; the "+" menu can fire from an unfocused panel without updating global group focus. newFileTab: async () => { + noteViewerNavigation() await openNewMarkdownInActiveWorkspace(groupId) }, newTerminalTab: () => { + noteViewerNavigation() void openNewTerminalTabInActiveWorkspace(groupId) }, newTerminalWithShell: (shellOverride: string) => { + noteViewerNavigation() void (async () => { const environmentId = runtimeEnvironmentIdOrLocalUnlessTwin( useAppStore.getState(), diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 662721214e45..b54233cb01bf 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -1,3 +1,4 @@ +import { noteViewerGroupNavigation } from '@/lib/viewer-selection-request' import { useShallow } from 'zustand/react/shallow' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' import type { Tab, TabGroup } from '../../../../shared/tab-types' @@ -88,6 +89,7 @@ export function useTabGroupWorkspaceModel({ expandedPaneByTabId: worktreeState.expandedPaneByTabId, commands: { focusGroup: () => { + noteViewerGroupNavigation(useAppStore.getState(), worktreeId, groupId) focusGroup(worktreeId, groupId) }, activateAgentSession, diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index db5995cf4ed8..ba007b41e4d5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -236,12 +236,12 @@ function TerminalContextMenuItems({ {isNativeChatView ? : } {isNativeChatView ? translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Continue with Terminal Agent' + 'components.tab.bar.SortableTabContextMenu.showAsTerminal', + 'Show as terminal' ) : translate( 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Continue in Chat UI' + 'Show as chat' )} {shortcuts.nativeChat ? ( {shortcuts.nativeChat} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0db5597d9b2c..8596c09f973d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -1,3 +1,4 @@ +import { noteViewerGroupNavigation } from '@/lib/viewer-selection-request' import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup } from '../../../../shared/tab-types' @@ -76,7 +77,10 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ ) const focusOwningGroup = useCallback( - (groupId: string) => focusGroup(worktreeId, groupId), + (groupId: string) => { + noteViewerGroupNavigation(useAppStore.getState(), worktreeId, groupId) + focusGroup(worktreeId, groupId) + }, [focusGroup, worktreeId] ) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.held-job.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.held-job.test.tsx new file mode 100644 index 000000000000..0b938f0753e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.held-job.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { + TEST_REPO, + makeWorktree, + makeTerminalTab +} from '@/store/slices/worktrees-slice-test-fixtures' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TerminalPaneResumeInChatButton } from './TerminalPaneResumeInChatButton' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +vi.mock('@/runtime/local-runtime-capabilities', () => ({ + ensureLocalRuntimeCapabilities: async () => [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + readLocalRuntimeCapabilitiesOrUnknown: () => [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + subscribeLocalRuntimeCapabilityObservation: () => () => {} +})) +afterEach(cleanup) +it.each(['suspended'] as const)( + 'keeps the real Claude header/menu switchable during held %s work and after exit', + () => { + useAppStore.setState(useAppStore.getInitialState(), true) + const paneKey = 'shell:3f2504e0-4f89-41d3-9a0c-0305e82c3301' + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }), + repos: [TEST_REPO], + worktreesByRepo: { repo1: [makeWorktree({ id: 'wt-1', repoId: 'repo1' })] }, + tabsByWorktree: { 'wt-1': [makeTerminalTab({ id: 'shell', worktreeId: 'wt-1' })] }, + terminalLayoutsByTabId: { + shell: { + root: { type: 'leaf', leafId: '3f2504e0-4f89-41d3-9a0c-0305e82c3301' }, + activeLeafId: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + expandedLeafId: null, + ptyIdsByLeafId: { '3f2504e0-4f89-41d3-9a0c-0305e82c3301': 'pty-1' } + } + } + }) + useAppStore + .getState() + .recordAgentProviderSession( + paneKey, + 'claude', + { key: 'session_id', id: 'real-claude' }, + undefined, + { worktreeId: 'wt-1', tabId: 'shell' } + ) + useAppStore.getState().setPaneForegroundAgent(paneKey, { + agent: 'claude', + shellForeground: true, + suspendedAgent: 'claude' + }) + render( + + + + ) + const header = screen.getByRole('button', { name: 'Switch to native chat', hidden: true }) + expect(header.hasAttribute('disabled')).toBe(false) + act(() => + useAppStore.getState().setPaneForegroundAgent(paneKey, { agent: null, shellForeground: true }) + ) + expect(header.hasAttribute('disabled')).toBe(false) + } +) + +vi.mock('@/components/confirmation-dialog-context', async (original) => ({ + ...(await original>()), + useConfirmationDialog: () => vi.fn(async () => false) +})) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx index 903f3795db31..2ca6285a97dc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx @@ -2,52 +2,66 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { TerminalPaneResumeInChatButton } from './TerminalPaneResumeInChatButton' import type { CliChatResumeSource } from '../tab-bar/cli-chat-resume-source' -const LOCAL_SOURCE: CliChatResumeSource = { - tabId: 'tab-1', - workspaceId: 'wt-1', - paneKey: 'tab-1:leaf', - agent: 'claude', - providerSessionId: 'provider-1', - executionHostId: 'local' -} - const mocks = vi.hoisted(() => { - const source: { current: CliChatResumeSource | null } = { current: null } + const source: CliChatResumeSource = { + tabId: 'tab-1', + workspaceId: 'wt-1', + paneKey: 'tab-1:leaf', + agent: 'claude', + providerSessionId: 'provider-1', + executionHostId: 'local', + ptyId: 'pty-1' + } return { source, resolve: vi.fn(), run: vi.fn(), pending: false } }) -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: Record) => unknown) => selector({}) -})) vi.mock('../tab-bar/cli-chat-resume-source', () => ({ + useCliChatResumeCapability: (_worktreeId: string, agent: string) => agent === 'codex', resolveCliChatResumeSource: (_state: unknown, args: unknown) => { mocks.resolve(args) - return mocks.source.current + return mocks.source } })) -vi.mock('../tab-bar/cli-chat-resume-run', () => ({ +vi.mock('@/lib/conversation-surface-switch', () => ({ runCliChatResume: mocks.run, - useCliChatResumePending: () => mocks.pending + useConversationSwitchReadiness: (args: { source?: CliChatResumeSource } | null) => ({ + ready: !mocks.pending && !args?.source?.hostUpdateRequired, + pending: mocks.pending + }) })) +afterEach(cleanup) beforeEach(() => { - mocks.source.current = LOCAL_SOURCE + mocks.source.agent = 'claude' + delete mocks.source.hostUpdateRequired + mocks.pending = false + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) }) + vi.clearAllMocks() }) -afterEach(cleanup) -it('dispatches the exact pane with owner-opening intent and shares pending', () => { +it.each([ + ['claude', 'Switch to native chat'], + ['codex', 'Switch to native chat'] +] as const)('offers the %s action for the exact pane and shares pending', (agent, label) => { + mocks.source.agent = agent + if (agent === 'claude') { + useAppStore.getState().setPaneForegroundAgent('tab-1:leaf', { agent, shellForeground: false }) + } const { rerender } = render( ) - fireEvent.click(screen.getByRole('button', { name: 'Continue in Chat UI' })) + fireEvent.click(screen.getByRole('button', { name: label })) expect(mocks.resolve).toHaveBeenCalledWith({ tab: { id: 'tab-1', worktreeId: 'wt-1' }, paneKey: 'tab-1:leaf' }) - expect(mocks.run).toHaveBeenCalledWith(LOCAL_SOURCE, true) + expect(mocks.run).toHaveBeenCalledWith(mocks.source, expect.any(Function)) mocks.pending = true rerender( @@ -61,18 +75,17 @@ it('dispatches the exact pane with owner-opening intent and shares pending', () it('keeps an older host visible but disabled, telling the user to update it', async () => { mocks.pending = false mocks.run.mockClear() - mocks.source.current = { - ...LOCAL_SOURCE, + Object.assign(mocks.source, { executionHostId: 'runtime:server-1', hostUpdateRequired: true - } + }) render( ) const button = screen.getByRole('button', { - name: 'Continue in Chat UI. Update Orca on this host to continue in Chat UI.' + name: 'Switch to native chat. Update Orca on this host to continue in Chat UI.' }) expect(button.hasAttribute('disabled')).toBe(true) const trigger = button.parentElement @@ -84,3 +97,8 @@ it('keeps an older host visible but disabled, telling the user to update it', as ).toBeGreaterThan(0) expect(mocks.run).not.toHaveBeenCalled() }) + +vi.mock('@/components/confirmation-dialog-context', async (original) => ({ + ...(await original>()), + useConfirmationDialog: () => vi.fn(async () => false) +})) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx index 2fbf117aa869..25b7446088dc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx @@ -1,11 +1,16 @@ +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useConversationSwitchReadiness, runCliChatResume } from '@/lib/conversation-surface-switch' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useShallow } from 'zustand/react/shallow' import { MessagesSquare } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { resolveCliChatResumeSource } from '../tab-bar/cli-chat-resume-source' -import { runCliChatResume, useCliChatResumePending } from '../tab-bar/cli-chat-resume-run' +import { + resolveCliChatResumeSource, + useCliChatResumeCapability +} from '../tab-bar/cli-chat-resume-source' export function TerminalPaneResumeInChatButton({ tabId, @@ -15,27 +20,40 @@ export function TerminalPaneResumeInChatButton({ tabId: string worktreeId: string paneKey: string -}): React.JSX.Element { +}): React.JSX.Element | null { const source = useAppStore( useShallow((state) => - resolveCliChatResumeSource(state, { - tab: { id: tabId, worktreeId }, - paneKey - }) + resolveCliChatResumeSource(state, { tab: { id: tabId, worktreeId }, paneKey }) ) ) - const pending = useCliChatResumePending(source) - const updateRequired = source?.hostUpdateRequired === true - const label = translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Continue in Chat UI' + const capability = useCliChatResumeCapability(worktreeId, source?.agent) + const confirm = useConfirmationDialog() + const readiness = useConversationSwitchReadiness( + source ? { direction: 'chat', ...source, source } : null ) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!enabled) { + return null + } + const label = translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat') + const updateRequired = source?.hostUpdateRequired === true const tooltip = updateRequired ? translate( 'components.native-chat.resumeInNewChat.hostUpdateRequired', 'Update Orca on this host to continue in Chat UI.' ) - : label + : !source + ? translate( + 'components.tab.bar.switch.noConversation', + 'No saved conversation is available in this terminal yet.' + ) + : (readiness.explanation ?? + (capability === false + ? label + : translate( + 'components.tab.bar.switch.toNativeChatTooltip', + 'Switch to native chat: continues the same conversation in a chat, in this tab.' + ))) return ( @@ -46,14 +64,14 @@ export function TerminalPaneResumeInChatButton({ type="button" variant="pane-title" size="icon-xs" - // A disabled button's tooltip is unreachable by screen readers, so the label carries the reason. aria-label={updateRequired ? `${label}. ${tooltip}` : label} - disabled={!source || pending || updateRequired} - aria-busy={pending || undefined} + disabled={!readiness.ready} + aria-disabled={!readiness.ready || undefined} + aria-busy={readiness.pending || undefined} onClick={(event) => { event.stopPropagation() - if (source) { - void runCliChatResume(source, true) + if (source && readiness.ready) { + void runCliChatResume(source, confirm) } }} > diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-session-handlers.ts b/src/renderer/src/components/terminal-pane/ipc-pty-session-handlers.ts index ad225b928bb3..4cea18cd0816 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-session-handlers.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-session-handlers.ts @@ -1,3 +1,4 @@ +import type { PtyExitOptions } from './pty-exit-delivery' import { drainRolledBackPtyShutdownData, isPtyDataHandlerShutdownPending, @@ -52,7 +53,7 @@ export function createIpcPtySessionHandlers({ writeUnavailable: () => void } >() - const ownedExitHandlers = new Map void>() + const ownedExitHandlers = new Map void>() function clearAccumulatedState(): void { outputProcessor.clearAccumulatedState() @@ -135,7 +136,7 @@ export function createIpcPtySessionHandlers({ function registerExit(id: string, incarnationId?: string): boolean { const hadBufferedExit = hasPreHandlerPtyExit(id, incarnationId) - const exit = (code: number): void => { + const exit = (code: number, options?: PtyExitOptions): void => { const currentId = getPtyId() if (currentId !== null && currentId !== id) { unregisterAll(id) @@ -145,9 +146,17 @@ export function createIpcPtySessionHandlers({ markExited() unregisterAll(id) const callbacks = getCallbacks() - callbacks.onExit?.(code) + if (options) { + callbacks.onExit?.(code, options) + } else { + callbacks.onExit?.(code) + } callbacks.onDisconnect?.() - onPtyExit?.(id, code) + if (options) { + onPtyExit?.(id, code, options) + } else { + onPtyExit?.(id, code) + } } ptyExitHandlers.set(id, exit) ownedExitHandlers.set(id, exit) diff --git a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts index 1d83dc4475f4..d62ce1008ecf 100644 --- a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts +++ b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts @@ -1,7 +1,8 @@ import type * as React from 'react' +import { Terminal } from '@xterm/headless' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { flushAsyncTicks } from './pty-connection-test-async' -import { createManager, createPane } from './pty-connection-test-pane-fixtures' +import { createManager, createPane, terminalWriteText } from './pty-connection-test-pane-fixtures' import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { installTerminalTestGlobals, @@ -29,6 +30,8 @@ import { type WindowLaunchCase } from '../../../../shared/launch-parity-window-request.test-fixture' import { mockPanePtyArgs } from './pty-connection-test-connect-mock-pane' +import { mainLayoutApplyTransport } from './pty-connection-test-main-pane-start-fixtures' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' // Main's answer to the window's layout commands, as a switch-on build sends them. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => @@ -79,6 +82,14 @@ const load = perClientLoader(async () => ({ paneOptions: await import('./terminal-pane-manager-options'), defaults: await import('@/lib/pane-manager/pane-terminal-options'), lifecycle: await import('./terminal-pane-lifecycle-primitives'), + close: await import('../terminal/terminal-tab-actions'), + layoutBindings: await import('./use-terminal-pane-layout-bindings'), + parked: await import('./terminal-parked-tab-watchers'), + parkedRegistry: await import('./terminal-parked-watcher-registry'), + dispatcher: await import('./pty-dispatcher'), + exitBuffer: await import('./pty-pre-handler-buffer'), + react: await import('react'), + server: await import('react-dom/server'), workspaceStart: await loadWorkspaceStartModules() })) @@ -219,6 +230,278 @@ describe('window-tab producers: the pty:spawn request a real pane sends', () => vi.unstubAllGlobals() }) + it('keeps a typed parked sole pane readable and unwritable through closing IPC and late reattach until explicit close', async () => { + const modules = await load('linux') + await installTerminalTestGlobals() + Object.assign(window.api.platform, { get: () => ({ platform: 'linux', osRelease: '6' }) }) + const workspace = { kind: 'folder', path: '/home/alice/source' } as const + const worktreeId = launchWorkspaceId(workspace) + const store = modules.createStore() + holder.store = store + store.setState(launchWorkspaceState(workspace, { ...getDefaultSettings('/home/alice') })) + const pane = createPane(1) + const manager = createManager(1, 1) + const ptyId = `${worktreeId}@@retained-source` + const incarnationId = 'source-incarnation' + const tab = store.getState().createTab(worktreeId, undefined, undefined, { + initialPtyId: ptyId, + initialLeafId: pane.leafId + }) + const spawn = vi.fn(async () => ({ + id: ptyId, + incarnationId, + isReattach: true + })) + const onData = vi.fn(() => () => {}) + const onExit = vi.fn(() => () => {}) + const noop = (): (() => void) => () => {} + Object.assign(window.api.pty, { + spawn, + onData, + onExit, + onReplay: noop, + onWriteUnavailable: noop, + onSideEffect: noop, + resize: vi.fn(), + claimViewport: vi.fn() + }) + const output = 'stopped source remains readable' + const sink = new Terminal({ + cols: pane.terminal.cols, + rows: pane.terminal.rows, + allowProposedApi: true + }) + const painted = Promise.withResolvers() + const reset = vi.fn(() => sink.reset()) + let binding: ReturnType | undefined + try { + if (!('_core' in sink)) { + throw new Error('headless terminal must expose the real user-input signal') + } + Object.assign(pane.terminal, { _core: sink._core, buffer: sink.buffer, reset }) + pane.terminal.onData.mockImplementation((listener: (data: string) => void) => + sink.onData(listener) + ) + pane.terminal.write.mockImplementation((data, callback) => { + sink.write(data, () => { + callback?.() + if (terminalWriteText(data).includes(output)) { + painted.resolve() + } + }) + }) + pane.terminal.clear.mockImplementation(() => sink.clear()) + const renderedText = (): string => + Array.from({ length: sink.buffer.active.length }, (_, row) => + sink.buffer.active.getLine(row)?.translateToString(true) + ).join('\n') + const automaticClose = vi.fn((exitedPtyId: string) => + modules.close.closeTerminalTab(tab.id, { + reason: 'pty-exit', + lifecyclePtyId: exitedPtyId + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real store supplies every state member read by the existing pane fixture. + const deps = buildPaneConnectionDeps(() => store.getState() as never, { + tabId: tab.id, + worktreeId, + cwd: workspace.path, + restoredLeafId: pane.leafId, + restoredPtyIdByLeafId: { [pane.leafId]: ptyId }, + onPtyExitRef: { current: automaticClose }, + consumeSuppressedPtyExit: vi.fn(store.getState().consumeSuppressedPtyExit), + clearTabPtyId: vi.fn(store.getState().clearTabPtyId), + updateTabPtyId: vi.fn(store.getState().updateTabPtyId) + }) + const args = mockPanePtyArgs(pane, manager, deps) + const controller: Partial< + Parameters[0] + > = { + tabId: tab.id, + managerRef: { current: args[1] }, + paneTransportsRef: args[2].paneTransportsRef, + containerRef: { current: null }, + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + persistLayoutSnapshot: vi.fn(), + setExpandedPaneId: vi.fn(), + setTabPaneExpanded: store.getState().setTabPaneExpanded, + setTabLayout: store.getState().setTabLayout + } + let layoutBindings: + | ReturnType + | undefined + function LayoutBindings(): null { + layoutBindings = modules.layoutBindings.useTerminalPaneLayoutBindings( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding hook reads only the supplied layout and expand/collapse controller fields, as in pending-split-close-test-fixture. + controller as Parameters[0] + ) + return null + } + modules.server.renderToString(modules.react.createElement(LayoutBindings)) + if (!layoutBindings) { + throw new Error('real pane layout bindings were not created') + } + Object.assign(args[2], { + syncPanePtyLayoutBinding: layoutBindings.syncPanePtyLayoutBinding, + clearExitedPanePtyLayoutBinding: vi.fn(layoutBindings.clearExitedPanePtyLayoutBinding) + }) + binding = modules.pane.connectPanePty(...args) + await flushAsyncTicks(20) + const transport = args[2].paneTransportsRef.current.get(pane.id) + const dispatchData = onData.mock.calls[0]?.[0] + const dispatchExit = onExit.mock.calls[0]?.[0] + if (!transport || !dispatchData || !dispatchExit) { + throw new Error('real IPC pane connection did not register its consumers') + } + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: ptyId })) + expect(transport.isConnected()).toBe(true) + expect(transport.getPtyId()).toBe(ptyId) + expect(store.getState().tabsByWorktree[worktreeId]).toEqual([tab]) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ contentType: 'terminal', entityId: tab.id }) + ]) + expect(manager.getPanes()).toEqual([{ id: pane.id, leafId: pane.leafId }]) + sink.input('status\r', true) + await flushAsyncTicks(20) + expect(window.api.pty.write).toHaveBeenCalledWith(ptyId, 'status\r', 'driving') + dispatchData({ id: ptyId, data: output }) + await painted.promise + expect(renderedText()).toContain(output) + const paneKey = `${tab.id}:${pane.leafId}` + const liveAnchor: SleepingAgentSessionRecord = { + paneKey, + tabId: tab.id, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: 'retained-source-conversation' }, + prompt: '', + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + store.setState({ + localOnlyScrollbackByTabId: { [tab.id]: { [pane.leafId]: output } }, + sleepingAgentSessionsByPaneKey: { [paneKey]: liveAnchor } + }) + const layoutApply = await mainLayoutApplyTransport() + expect(layoutApply.sentOfType('startPane')).toHaveLength(0) + const registerLaunch = vi.spyOn(store.getState(), 'registerAgentLaunchConfig') + const beforeExit = store.getState() + const layout = beforeExit.terminalLayoutsByTabId[tab.id] + expect(layout.root).toEqual({ type: 'leaf', leafId: pane.leafId }) + expect(layout.ptyIdsByLeafId).toEqual({ [pane.leafId]: ptyId }) + const writes = vi.mocked(window.api.pty.write).mock.calls.length + const clears = pane.terminal.clear.mock.calls.length + const resets = reset.mock.calls.length + const retireTab = vi.spyOn(store.getState(), 'closeTab') + + modules.parked.captureParkedTerminalPaneCandidates(tab.id, worktreeId, [ + { + ptyId, + paneId: pane.id, + leafId: pane.leafId, + drivesTabTitle: true, + untouchedFreshSpawn: binding.isUntouchedFreshSpawnPty(ptyId) + } + ]) + transport.detach?.({ preserveExitObserver: false }) + binding.dispose() + args[2].paneTransportsRef.current.clear() + modules.parked.syncParkedTerminalTabWatchersForWorkspaces( + new Map([[worktreeId, { tabs: [tab], parkedTabIds: new Set([tab.id]) }]]) + ) + expect(modules.dispatcher.ptyExitHandlers.has(ptyId)).toBe(false) + expect( + modules.parkedRegistry.parkedWatchersByTabId.get(tab.id)?.disposersByPtyId.has(ptyId) + ).toBe(true) + expect(store.getState().isPtyShutdownPending(ptyId)).toBe(false) + expect(store.getState().suppressedPtyExitIds[ptyId]).toBeUndefined() + dispatchExit({ id: ptyId, code: 0, incarnationId, preserveRendererBinding: 'closing' }) + expect(modules.exitBuffer.hasPreHandlerPtyExit(ptyId, incarnationId)).toBe(true) + expect(modules.parkedRegistry.parkedWatchersByTabId.get(tab.id)?.disposersByPtyId.size).toBe( + 0 + ) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toEqual(layout) + expect(store.getState().ptyIdsByTabId[tab.id]).toEqual([ptyId]) + const afterMarkerGrace = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 30_001) + try { + args[2].mountFollowsTerminalPark = true + binding = modules.pane.connectPanePty(...args) + await flushAsyncTicks(20) + } finally { + afterMarkerGrace.mockRestore() + } + const revealedTransport = args[2].paneTransportsRef.current.get(pane.id) + if (!revealedTransport) { + throw new Error('real reveal did not install its transport') + } + expect(revealedTransport).not.toBe(transport) + expect(modules.exitBuffer.hasPreHandlerPtyExit(ptyId)).toBe(false) + await flushAsyncTicks(20) + await layoutApply.settle() + await flushAsyncTicks(20) + expect(layoutApply.sentOfType('startPane')).toHaveLength(0) + expect(registerLaunch).not.toHaveBeenCalled() + expect(deps.consumeSuppressedPtyExit).toHaveReturnedWith(false) + expect(transport.isConnected()).toBe(false) + expect(transport.getPtyId()).toBeNull() + expect(revealedTransport.isConnected()).toBe(false) + expect(revealedTransport.getPtyId()).toBeNull() + expect(revealedTransport.sendInput('must not run\r', 'driving')).toBe(false) + expect(window.api.pty.write).toHaveBeenCalledTimes(writes) + expect(automaticClose).not.toHaveBeenCalled() + expect(retireTab).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(args[2].clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[worktreeId]).toEqual( + beforeExit.tabsByWorktree[worktreeId] + ) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual( + beforeExit.unifiedTabsByWorktree[worktreeId] + ) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toEqual(layout) + expect(store.getState().ptyIdsByTabId[tab.id]).toEqual([ptyId]) + expect(store.getState().localOnlyScrollbackByTabId[tab.id]).toEqual({ [pane.leafId]: output }) + expect(pane.terminal.clear).toHaveBeenCalledTimes(clears) + expect(reset).toHaveBeenCalledTimes(resets) + expect(renderedText()).toContain(output) + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({ [paneKey]: liveAnchor }) + expect(args[2].paneTransportsRef.current.get(pane.id)).toBe(revealedTransport) + expect(spawn).toHaveBeenCalledTimes(1) + + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + await new Promise((resolve) => + modules.close.closeTerminalTab(tab.id, { reason: 'user', onClosed: resolve }) + ) + expect(retireTab).toHaveBeenCalledExactlyOnceWith( + tab.id, + expect.objectContaining({ reason: 'user' }) + ) + expect(store.getState().tabsByWorktree[worktreeId] ?? []).toEqual([]) + expect(store.getState().unifiedTabsByWorktree[worktreeId] ?? []).toEqual([]) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toBeUndefined() + expect(store.getState().localOnlyScrollbackByTabId[tab.id]).toBeUndefined() + expect(store.getState().ptyIdsByTabId[tab.id]).toBeUndefined() + expect(transport.sendInput('still must not run\r', 'driving')).toBe(false) + expect(revealedTransport.sendInput('still must not run\r', 'driving')).toBe(false) + expect(window.api.pty.write).toHaveBeenCalledTimes(writes) + expect(spawn).toHaveBeenCalledTimes(1) + } finally { + binding?.dispose() + modules.parkedRegistry.disposeAllParkedTerminalWatchers() + sink.dispose() + } + }) + it.each([...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES, ...WORKSPACE_START_LAUNCH_CASES])( 'row $row: $name', async (c) => { diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-retry.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-retry.ts new file mode 100644 index 000000000000..3ab8df9e0a1a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-retry.ts @@ -0,0 +1,18 @@ +import { isAgentForegroundWrapperProcess } from '../../../../shared/agent-process-recognition' +import type { ForegroundReadReason } from './pane-foreground-agent-tracker-contract' + +export function shouldRetryForegroundAgentRead( + hasConfirmationExpectation: boolean, + reason: ForegroundReadReason, + processName: string | null, + retryDelay: number | undefined +): boolean { + const shouldRetryExpectedIdentity = + hasConfirmationExpectation && (reason !== 'command-finished' || processName === null) + const shouldRetry = + retryDelay !== undefined && + (shouldRetryExpectedIdentity || + (processName !== null && + (reason === 'command' || isAgentForegroundWrapperProcess(processName)))) + return shouldRetry +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts index 83274a01746a..7be00ec79f8e 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts @@ -1,3 +1,4 @@ +import type { TerminalAgent } from '../../../../shared/terminal-agent' import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' @@ -32,3 +33,16 @@ export type PaneForegroundAgentTrackerDeps = { onCommandFinishedUnavailable?: () => void onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void } + +export type PaneForegroundAgentTracker = { + /** True while any read is scheduled or running, whatever its authority. */ + hasReadInFlight: () => boolean + onVisiblePtyBound: (expectsAgent?: boolean) => boolean + onCommandStarted: (expectedAgent?: TerminalAgent | null) => void + onAgentStartupObserved: () => void + /** True when pane identity must remain visible until an async shell confirmation. */ + onCommandFinished: () => boolean + /** The process monitor confirmed this agent exited (no agent, no children, settled). */ + onProcessExitConfirmed: (process: RecognizedAgentProcess) => void + dispose: () => void +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 387a076b0049..2ddc941b3f47 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -1,33 +1,21 @@ -import { - isAgentForegroundWrapperProcess, - recognizeAgentProcess, - type RecognizedAgentProcess -} from '../../../../shared/agent-process-recognition' -import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' import { isShellProcess } from '../../../../shared/shell-process-detection' -import type { TerminalAgent } from '../../../../shared/terminal-agent' import { createPaneForegroundProcessReader } from './pane-foreground-process-reader' import { FOREGROUND_COMMAND_READS } from '../../../../shared/foreground-command-settle' import type { ForegroundReadReason, - PaneForegroundAgentTrackerDeps + PaneForegroundAgentTrackerDeps, + PaneForegroundAgentTracker } from './pane-foreground-agent-tracker-contract' +import { confirmPaneForegroundProcessExit } from './pane-foreground-process-exit' +import { shouldRetryForegroundAgentRead } from './pane-foreground-agent-retry' const VISIBLE_PTY_SETTLE_MS = 350 /** Read foreground identity at command boundaries; confirm 133;D before clearing known agents because nested shells can leak that marker. */ -export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): { - /** True while any read is scheduled or running, whatever its authority. */ - hasReadInFlight: () => boolean - onVisiblePtyBound: (expectsAgent?: boolean) => boolean - onCommandStarted: (expectedAgent?: TerminalAgent | null) => void - onAgentStartupObserved: () => void - /** True when pane identity must remain visible until an async shell confirmation. */ - onCommandFinished: () => boolean - /** The process monitor confirmed this agent exited (no agent, no children, settled). */ - onProcessExitConfirmed: (process: RecognizedAgentProcess) => void - dispose: () => void -} { +export function createPaneForegroundAgentTracker( + deps: PaneForegroundAgentTrackerDeps +): PaneForegroundAgentTracker { let disposed = false let readTimer: ReturnType | null = null let scheduledReadReason: ForegroundReadReason | null = null @@ -115,10 +103,13 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation - const { processName, remoteEvidenceVerdict, expectedIncarnationId, remote } = await readProcess( - ptyId, - requiresRoutingConfirmation - ) + const { + processName, + suspendedAgentProcess, + remoteEvidenceVerdict, + expectedIncarnationId, + remote + } = await readProcess(ptyId, requiresRoutingConfirmation) // Fence pending inspection so an old PTY cannot publish identity into its replacement. if ( disposed || @@ -129,6 +120,23 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke settleAbortedRead(generation) return } + const suspended = recognizeAgentProcess(suspendedAgentProcess ?? null) + if (suspended) { + hasKnownAgentEvidence = true + hasAgentExpectation = false + deps.publish({ + agent: null, + suspendedAgent: suspended.agent, + agentEvidence: 'process-read', + shellForeground: true, + routingTrusted: false, + routingRevoked: true + }) + if (reason === 'visible-pty') { + deps.onVisibleForegroundSettled?.('agent') + } + return + } const recognized = remoteEvidenceVerdict === 'live' ? recognizeAgentProcess(processName) @@ -157,13 +165,12 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const retryDelay = FOREGROUND_COMMAND_READS.retryDelaysMs[retryIndex] const hasConfirmationExpectation = hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation - const shouldRetryExpectedIdentity = - hasConfirmationExpectation && (reason !== 'command-finished' || processName === null) - const shouldRetry = - retryDelay !== undefined && - (shouldRetryExpectedIdentity || - (processName !== null && - (reason === 'command' || isAgentForegroundWrapperProcess(processName)))) + const shouldRetry = shouldRetryForegroundAgentRead( + hasConfirmationExpectation, + reason, + processName, + retryDelay + ) if (shouldRetry) { // Bound retries across PowerShell-to-WMIC enrichment, which may initially hide a live agent. scheduleRead(retryDelay, retryIndex + 1, reason) @@ -220,9 +227,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return { // A higher-authority command read still promises confirmation when visibility schedules nothing. - hasReadInFlight(): boolean { - return hasPendingRead() - }, + hasReadInFlight: hasPendingRead, onVisiblePtyBound(expectsAgent = false) { // Lower-authority visibility recovery must not cancel command-boundary reads that own the exit decision. if (hasCommandReadInFlight()) { @@ -302,26 +307,18 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return true }, onProcessExitConfirmed(process) { - // Why: an in-flight command read owns the pane's next identity, and only a live read is ours. - const published = deps.getPublishedEntry?.() - if ( - disposed || - hasCommandReadInFlight() || - published?.agentEvidence !== 'process-read' || - resolveCompatibleAgentTypeForOwner(process.agent, published.agent) !== published.agent - ) { - return - } - const hadVisibleRead = hasPendingRead() - cancelPendingRead() - clearAgentEvidence() - startupSampled = false - // Without command marks, shellForeground would never lift and would block Enter from identifying the next agent. - deps.publish({ agent: null, shellForeground: false }) - if (hadVisibleRead) { - deps.onVisibleForegroundSettled?.('shell') - } - deps.onConfirmedShellForeground?.('process-exit') + confirmPaneForegroundProcessExit({ + deps, + process, + disposed, + commandReadInFlight: hasCommandReadInFlight(), + pendingRead: hasPendingRead(), + cancelPendingRead, + clearAgentEvidence, + resetStartupSample: () => { + startupSampled = false + } + }) }, dispose() { const hadReadAtDispose = hasPendingRead() diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit.ts new file mode 100644 index 000000000000..7bb37f25812e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit.ts @@ -0,0 +1,46 @@ +import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' +import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' + +import type { PaneForegroundAgentTrackerDeps } from './pane-foreground-agent-tracker-contract' + +export function confirmPaneForegroundProcessExit(args: { + deps: PaneForegroundAgentTrackerDeps + process: RecognizedAgentProcess + disposed: boolean + commandReadInFlight: boolean + pendingRead: boolean + cancelPendingRead: () => void + clearAgentEvidence: () => void + resetStartupSample: () => void +}): void { + const { + deps, + process, + disposed, + commandReadInFlight, + pendingRead, + cancelPendingRead, + clearAgentEvidence, + resetStartupSample + } = args + // Why: an in-flight command read owns the pane's next identity, and only a live read is ours. + const published = deps.getPublishedEntry?.() + if ( + disposed || + commandReadInFlight || + published?.agentEvidence !== 'process-read' || + resolveCompatibleAgentTypeForOwner(process.agent, published.agent) !== published.agent + ) { + return + } + const hadVisibleRead = pendingRead + cancelPendingRead() + clearAgentEvidence() + resetStartupSample() + // Without command marks, shellForeground would never lift and would block Enter from identifying the next agent. + deps.publish({ agent: null, shellForeground: false }) + if (hadVisibleRead) { + deps.onVisibleForegroundSettled?.('shell') + } + deps.onConfirmedShellForeground?.('process-exit') +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts index 51627feb119e..45e75845d1e4 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts @@ -22,6 +22,7 @@ export function createPaneForegroundProcessReader(deps: { return async (ptyId: string, requiresConfirmation: boolean) => { let processName: string | null = null + let suspendedAgentProcess: string | undefined let remoteEvidenceVerdict: 'live' | 'unverifiable' | 'exited' | null = null const expectedIncarnationId = deps.getExpectedIncarnationId?.() ?? null const options = expectedIncarnationId ? { expectedIncarnationId } : undefined @@ -66,9 +67,15 @@ export function createPaneForegroundProcessReader(deps: { remoteEvidenceVerdict = admitted?.verdict ?? 'unverifiable' if (admitted?.verdict === 'live') { processName = admitted.processName + suspendedAgentProcess = admitted.suspendedAgentProcess } } else { processName = inspection.foregroundProcess + suspendedAgentProcess = + inspection.suspendedAgentProcess ?? + (inspection.foregroundProcessEvidence?.verdict === 'live' + ? inspection.foregroundProcessEvidence.suspendedAgentProcess + : undefined) } } catch { // Missing legacy/injected-provider answers remain unverifiable remote verdicts. @@ -76,6 +83,7 @@ export function createPaneForegroundProcessReader(deps: { } return { processName, + suspendedAgentProcess, remoteEvidenceVerdict, expectedIncarnationId, remote diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts index df0a0abbf4f2..8082aac8f226 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts @@ -159,8 +159,8 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('keeps Droid routing visible through command-finished foreground confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - const getForegroundProcess = vi.mocked(window.api.pty.confirmForegroundProcess) - getForegroundProcess.mockResolvedValue('droid') + const inspectProcess = vi.mocked(window.api.pty.inspectProcess) + inspectProcess.mockResolvedValue({ foregroundProcess: 'droid', hasChildProcesses: false }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-droid-confirmation-window' const transport = createMockTransport(ptyId) @@ -190,7 +190,7 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { delete mockStoreState.agentLaunchConfigByPaneKey[key] }) - const readsBeforeFinish = getForegroundProcess.mock.calls.length + const readsBeforeFinish = inspectProcess.mock.calls.length dataCallbackRef.current?.('\x1b]133;D;0\x07') expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') @@ -199,7 +199,7 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - expect(getForegroundProcess).toHaveBeenCalledTimes(readsBeforeFinish + 1) + expect(inspectProcess).toHaveBeenCalledTimes(readsBeforeFinish + 1) expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', @@ -213,7 +213,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('retires pane launch routing after one fresh scan confirms shell', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'powershell.exe', + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-droid-confirmed-shell' const transport = createMockTransport(ptyId) @@ -259,7 +262,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { ])('%s', async (_label, stoppingOnPurpose) => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-sleeping-confirmed-shell' const transport = createMockTransport(ptyId) @@ -368,7 +374,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { // Import after resetModules to share pty-connection’s registry; static imports would assert against a different instance. const { createCommandFinishedStatusStore } = await import('./command-finished-status-store-test-fixture') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'powershell.exe', + hasChildProcesses: false + }) vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-claude-row-confirmed-shell' @@ -414,7 +423,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('does NOT reconcile when the process check could not confirm a shell', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: false + }) vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-unconfirmed-shell' @@ -458,7 +470,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('bash') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'bash', + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-stale-mode-disarm' const transport = createMockTransport(ptyId) @@ -504,8 +519,9 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('grounds xterm and the input mirror in stream order when output is still queued', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - const confirmRead = createDeferred() - vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(confirmRead.promise) + const confirmRead = + createDeferred>>() + vi.mocked(window.api.pty.inspectProcess).mockReturnValue(confirmRead.promise) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-stale-mode-queued-output' const transport = createMockTransport(ptyId) @@ -531,12 +547,12 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { dataCallbackRef.current?.('\x1b]133;D;0\x07') await vi.advanceTimersByTimeAsync(350) await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalled() + expect(window.api.pty.inspectProcess).toHaveBeenCalled() // Hidden-pane output sits in the background queue when the shell proof lands. dataCallbackRef.current?.('\x1b[>5u') expect(writes.some((w) => w.includes('\x1b[>5u'))).toBe(false) - confirmRead.resolve('bash') + confirmRead.resolve({ foregroundProcess: 'bash', hasChildProcesses: false }) await flushAsyncTicks() await vi.advanceTimersByTimeAsync(100) @@ -589,7 +605,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-droid-unavailable-finish' const tabId = 'tab-droid-unavailable-finish' @@ -635,7 +654,10 @@ export function registerPtyConnectionCommandFinishedCleanupTests(): void { it('cancels deferred cleanup on a new command and retires identity at its later shell', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'powershell.exe', + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-droid-superseded-confirmation' const transport = createMockTransport(ptyId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts index e68028b8ec02..2885685336f6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts @@ -109,7 +109,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { it('clears pre-hook launch config when an Orca-started command exits', async () => { vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() @@ -173,9 +176,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { const ptyId = 'pty-manually-typed-droid' const tabId = 'tab-manually-typed-droid' const foregroundResults = ['powershell.exe', 'droid'] - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async (id: string) => - id === ptyId ? (foregroundResults.shift() ?? 'droid') : null - ) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (id: string) => ({ + foregroundProcess: id === ptyId ? (foregroundResults.shift() ?? 'droid') : null, + hasChildProcesses: false + })) const transport = createMockTransport(ptyId) transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -215,7 +219,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { it('confirms a manually typed Droid without OSC command boundaries', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'droid', + hasChildProcesses: false + }) const pane = createPane(1) const ptyId = 'pty-manual-droid-no-osc' const tabId = 'tab-manual-droid-no-osc' @@ -237,7 +244,9 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { await vi.advanceTimersByTimeAsync(350) - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith(ptyId, { + scanChildProcesses: true + }) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', agentEvidence: 'process-read', @@ -252,7 +261,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'droid', + hasChildProcesses: false + }) const pane = createPane(1) const ptyId = 'pty-launched-droid-no-osc' const tabId = 'tab-launched-droid-no-osc' @@ -283,7 +295,9 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { ) await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith(ptyId, { + scanChildProcesses: true + }) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', agentEvidence: 'process-read', @@ -298,9 +312,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( - async () => foregroundResults.shift() ?? 'droid' - ) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async () => ({ + foregroundProcess: foregroundResults.shift() ?? 'droid', + hasChildProcesses: false + })) const pane = createPane(1) const ptyId = 'pty-launched-droid-slow-boot' const tabId = 'tab-launched-droid-slow-boot' @@ -342,7 +357,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { let foreground = 'bash.exe' // Why both: the pane's process monitor reads the same foreground the confirm read sees. vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async () => ({ + foregroundProcess: foreground, + hasChildProcesses: false + })) const pane = createPane(1) const ptyId = 'pty-launched-droid-slow' const tabId = 'tab-launched-droid-slow' @@ -394,7 +412,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { const { connectPanePty } = await import('./pty-connection') let foreground = 'sleep' vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async () => ({ + foregroundProcess: foreground, + hasChildProcesses: false + })) const tabId = 'tab-delayed-claude' const ptyId = 'pty-delayed-claude' const paneKey = makePaneKey(tabId, LEAF_1) @@ -428,7 +449,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'cmd.exe', + hasChildProcesses: false + }) const pane = createPane(1) const ptyId = 'pty-droid-exit-no-osc' const tabId = 'tab-droid-exit-no-osc' @@ -470,7 +494,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { const { connectPanePty } = await import('./pty-connection') let foreground = 'pi' vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('pi') - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async () => ({ + foregroundProcess: foreground, + hasChildProcesses: false + })) const pane = createPane(1) const ptyId = 'pty-pi-no-osc-lifecycle' const tabId = 'tab-pi-no-osc-lifecycle' @@ -524,10 +551,10 @@ export function registerPtyConnectionForegroundAgentRoutingTests(): void { it('never promotes typed Droid text when foreground enrichment is unavailable', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess) - .mockResolvedValueOnce(null) + vi.mocked(window.api.pty.inspectProcess) + .mockResolvedValueOnce({ foregroundProcess: null, hasChildProcesses: false }) .mockRejectedValueOnce(new Error('inspection unavailable')) - .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ foregroundProcess: null, hasChildProcesses: false }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const pane = createPane(1) const ptyId = 'pty-typed-droid-unavailable-start' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts index ba0961f43e2a..65061ce51994 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts @@ -229,7 +229,9 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { await vi.advanceTimersByTimeAsync(10_000) // Scope to this pane's pty id: a delayed confirm for another test's pane can fire during this advance. - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalledWith(ptyId, { + scanChildProcesses: true + }) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', routingRevoked: true, @@ -272,7 +274,9 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { binding.sampleForegroundAgentOnFocus() await vi.advanceTimersByTimeAsync(10_000) - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalledWith(ptyId, { + scanChildProcesses: true + }) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', routingRevoked: true, @@ -303,7 +307,10 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { state: 'working', agentType: 'droid' } - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'droid', + hasChildProcesses: false + }) binding.requestWindowsShiftEnterReconfirmation() await vi.advanceTimersByTimeAsync(200) @@ -327,7 +334,9 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { await vi.advanceTimersByTimeAsync(350) await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith(ptyId, { + scanChildProcesses: true + }) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', agentEvidence: 'process-read', @@ -363,7 +372,10 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { state: 'working', agentType: 'pi' } - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'cmd.exe', + hasChildProcesses: false + }) binding.sampleForegroundAgentOnFocus() @@ -404,7 +416,10 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { it('confirms daemon launch identity before restoring warm-reattach routing', async () => { vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'droid', + hasChildProcesses: false + }) const ptyId = 'pty-launch-identity-no-sample' const tabId = `tab-${ptyId}` mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } @@ -496,7 +511,10 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { it('retires stale daemon launch identity when warm reattach finds the shell', async () => { vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'powershell.exe', + hasChildProcesses: false + }) const ptyId = 'pty-stale-daemon-launch-identity' const tabId = `tab-${ptyId}` mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } @@ -514,9 +532,7 @@ export function registerPtyConnectionForegroundAgentSamplingTests(): void { await flushAsyncTicks() // Assert the floor (initial read + two retries), not an exact count: an incidental droid reconfirm can add one more. - expect( - vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length - ).toBeGreaterThanOrEqual(3) + expect(vi.mocked(window.api.pty.inspectProcess).mock.calls.length).toBeGreaterThanOrEqual(3) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: null, shellForeground: true diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts index 330dd9deb652..a320255240b8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts @@ -160,7 +160,7 @@ export function registerPtyConnectionForegroundRoutingConfirmationTests(): void it('does not let a pending confirmation re-arm itself', async () => { vi.useFakeTimers() // Never resolves: the confirmation stays in flight for the whole test. - vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(new Promise(() => {})) + vi.mocked(window.api.pty.inspectProcess).mockReturnValue(new Promise(() => {})) const ptyId = 'pty-pi-no-remint' const tabId = `tab-${ptyId}` const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ @@ -219,11 +219,14 @@ export function registerPtyConnectionForegroundRoutingConfirmationTests(): void async (outcome) => { vi.useFakeTimers() if (outcome === 'provider rejection') { - vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue( + vi.mocked(window.api.pty.inspectProcess).mockRejectedValue( new Error('inspection unavailable') ) } else { - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: false + }) } const ptyId = `pty-pi-reconfirm-${outcome.replace(' ', '-')}` const tabId = `tab-${ptyId}` @@ -258,7 +261,10 @@ export function registerPtyConnectionForegroundRoutingConfirmationTests(): void it('replaces pending Pi routing when confirmation finds another agent', async () => { vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex', + hasChildProcesses: false + }) const ptyId = 'pty-pi-reconfirm-codex' const tabId = `tab-${ptyId}` const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts index 6b59c0d3cb6b..9f4bd8cc3e55 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts @@ -156,7 +156,10 @@ describe('shell showing through a hibernation kill', () => { }> { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-hibernating' const transport = createMockTransport(ptyId) @@ -210,7 +213,7 @@ describe('shell showing through a hibernation kill', () => { exitPty: () => { const onPtyExit = createdTransportOptions[0]?.onPtyExit if (typeof onPtyExit === 'function') { - onPtyExit(ptyId) + onPtyExit(ptyId, 0, { preserveRendererBinding: true }) } } } @@ -236,19 +239,19 @@ describe('shell showing through a hibernation kill', () => { 'preserves the wake record when a pending confirmation settles with PTY %s', async (boundPtyId) => { const pane = await connectHibernatingPane(true) - const foreground = createDeferred() - vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(foreground.promise) + const foreground = createDeferred>>() + vi.mocked(window.api.pty.inspectProcess).mockReturnValue(foreground.promise) pane.finishCommand() await vi.advanceTimersByTimeAsync(350) - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledExactlyOnceWith( - 'pty-hibernating' - ) + expect(window.api.pty.inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-hibernating', { + scanChildProcesses: true + }) pane.exitPty() pane.rebindPty(boundPtyId) await flushAsyncTicks() expect(mockStoreState.suppressedPtyExitIds).toEqual({}) - foreground.resolve('zsh') + foreground.resolve({ foregroundProcess: 'zsh', hasChildProcesses: false }) await flushAsyncTicks() // A detached transport rejects the stale read; a retained binding reaches shell cleanup. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts index 72c57e9a4604..8941c9acbe00 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts @@ -65,6 +65,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('disarms input modes and resumes a hibernated agent session on visibility reveal', async () => { // Regression: hibernation suppresses its kill's PTY exit while hidden; before the wake fix onExit permanently latched handledExitPtyId, leaving a frozen inert ghost pane on reveal. const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const manager = createManager(1) @@ -105,7 +106,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') const connectCallsBeforeExit = transport.connect.mock.calls.length + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() // The frozen frame's input-eating modes are disarmed at hibernation-exit (mouse tracking / bracketed paste would otherwise swallow clicks). @@ -143,65 +146,125 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) // A suppressed exit over a finished turn’s live anchor still arms reveal resume despite its worktree-sleep note (#16308). - it('arms an in-place resume from a finished turn idle anchor on a suppressed exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - await mainStartsPaneAs({ - ptyId: 'pty-main', - bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + it.each([10, 100])( + 'arms an in-place resume from a finished turn idle anchor without scanning %i unrelated hosts', + async (hostCount) => { + const { connectPanePty } = await import('./pty-connection') + const { + settleDeferredPtyShutdownExits, + applyHostWorktreeTerminalSleepState, + consumeCommittedPtyShutdownExit + } = await import('./pty-shutdown-exit-deferral') + const { toRemoteRuntimePtyId } = + await import('../../../../shared/remote-runtime/remote-runtime-pty-id') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } }) - } - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-idle-anchor' }, - prompt: '', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'live' - } - mockStoreState.suppressedPtyExitIds['tab-pty'] = true - const args = mockPanePtyArgs(pane, createManager(1), deps) - const binding = connectPanePty(...args) - await flushAsyncTicks() - const onPtyExit = createdTransportOptions[0]?.onPtyExit - expect(onPtyExit).toBeTypeOf('function') - const connectCallsBeforeExit = transport.connect.mock.calls.length - if (typeof onPtyExit === 'function') { - onPtyExit('tab-pty') - } - await flushAsyncTicks() + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-idle-anchor' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + const args = mockPanePtyArgs(pane, createManager(1), deps) + const binding = connectPanePty(...args) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit + expect(onPtyExit).toBeTypeOf('function') + const connectCallsBeforeExit = transport.connect.mock.calls.length + const unrelatedHosts = Array.from( + { length: hostCount }, + (_, i) => `env-unrelated-replay-${i}` + ) + for (const environmentId of unrelatedHosts) { + applyHostWorktreeTerminalSleepState(environmentId, { + type: 'worktreeTerminalSleepState', + worktreeId: 'unrelated-workspace', + generation: 1, + phase: 'committed', + ptyIds: [], + terminalHandles: ['unrelated-terminal'] + }) + } + let unrelatedHostVisits = 0 + const iterate = Map.prototype[Symbol.iterator] + Object.defineProperty(Map.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: function* (this: Map) { + for (const entry of iterate.call(this)) { + if (typeof entry[0] === 'string' && entry[0].startsWith('env-unrelated-replay-')) { + unrelatedHostVisits += 1 + } + yield entry + } + return undefined + } + }) + try { + deps.isPtyShutdownPending.mockReturnValueOnce(true) + if (typeof onPtyExit === 'function') { + onPtyExit('tab-pty') + } + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') + } finally { + Object.defineProperty(Map.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: iterate + }) + for (const environmentId of unrelatedHosts) { + expect( + consumeCommittedPtyShutdownExit( + toRemoteRuntimePtyId('unrelated-terminal', environmentId), + environmentId + ) + ).toBe(true) + } + } + expect(unrelatedHostVisits).toBe(0) + console.info('R110_HOST_REPLAY', { hostCount, unrelatedHostVisits }) + await flushAsyncTicks() - binding.noteVisibilityResume() - await flushAsyncTicks() + binding.noteVisibilityResume() + await flushAsyncTicks() - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - await expectWindowStartAskedMain(transport, paneKey) - expect(transport.connect).toHaveBeenLastCalledWith( - expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) - ) - return + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + return + } + const resumeConnectOptions: { command?: string } | undefined = + transport.connect.mock.calls.at(-1)?.[0] + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + expect(resumeConnectOptions?.command).toContain('sess-idle-anchor') } - const resumeConnectOptions: { command?: string } | undefined = - transport.connect.mock.calls.at(-1)?.[0] - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - expect(resumeConnectOptions?.command).toContain('sess-idle-anchor') - }) + ) it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { // Mobile wakes hidden panes without visibility changes; duplicate wake events must resume exactly once (INV-1). const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const manager = createManager(1) @@ -239,7 +302,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { | undefined expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') const connectCallsBeforeExit = transport.connect.mock.calls.length + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() // Still hidden: no reveal happened, so nothing respawned on exit. expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) @@ -278,6 +343,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('names the replaced PTY on the restart spawn only, never on a later wake of the same pane', async () => { const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-restarted') transportFactoryQueue.push(transport) const deps = createDeps({ @@ -323,7 +389,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string) => void) | undefined + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('pty-restarted') + settleDeferredPtyShutdownExits(['pty-restarted'], 'committed') await flushAsyncTicks() expect(binding.wakeHibernatedAgentIfArmed(new Set())).not.toBeNull() await flushAsyncTicks() @@ -411,6 +479,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => { // Latch wake events between recording sleep and arming the kill’s PTY ID, or the frozen pane remains stranded (#7906). const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const manager = createManager(1) @@ -455,7 +524,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string) => void) | undefined + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() if (ORCA_LAYOUT_OWNER_IS_MAIN) { @@ -476,6 +547,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => { const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const deps = createDeps({ @@ -501,7 +573,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string) => void) | undefined + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() const deferredSpawn = createDeferred() transport.connect.mockImplementationOnce(() => deferredSpawn.promise) @@ -535,6 +609,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('re-arms the exact hibernation target after a replacement spawn fails', async () => { const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const paneKey = `tab-1:${leafIdForPane(2)}` @@ -550,22 +625,19 @@ export function registerPtyConnectionHibernationWakeTests(): void { updatedAt: 1, origin: 'worktree-sleep' } - const binding = connectPanePty( - ...mockPanePtyArgs( - createPane(2), - createManager(1), - createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - ) - ) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const binding = connectPanePty(...mockPanePtyArgs(createPane(2), createManager(1), deps)) await flushAsyncTicks() const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string) => void) | undefined + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) @@ -624,6 +696,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { // If reveal precedes wake arming, the arm-time visibility check must resume the pane immediately. const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const manager = createManager(1) @@ -662,7 +735,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') const connectCallsBeforeExit = transport.connect.mock.calls.length + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() if (ORCA_LAYOUT_OWNER_IS_MAIN) { @@ -691,6 +766,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { it('invalidates the hibernation wake when another flow rebinds the pane before reveal', async () => { // A hidden restart’s rebind permanently invalidates the old armed wake, including after a later PTY death. const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const manager = createManager(1) @@ -722,7 +798,9 @@ export function registerPtyConnectionHibernationWakeTests(): void { expect(onPtyExit).toBeTypeOf('function') expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') const connectCallsBeforeExit = transport.connect.mock.calls.length + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('tab-pty') + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() // Another flow rebinds the pane to a fresh PTY while it is still hidden. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts index 8d939d727a2e..d6f04ac19fa2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts @@ -328,14 +328,13 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { const isVisibleRef = { current: false } const transport = createResumingTransport() transportFactoryQueue.push(transport) - const binding = connectPane( - buildPaneConnectionDeps(() => mockStoreState, { - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: {}, - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef - }) - ) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: {}, + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef + }) + const binding = connectPane(deps) await flushAsyncTicks(10) isVisibleRef.current = true binding.noteVisibilityResume() @@ -351,7 +350,10 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { if (typeof onPtyExit !== 'function') { throw new Error('expected the transport exit handler') } + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit(resumedPtyId) + settleDeferredPtyShutdownExits([resumedPtyId], 'committed') await flushAsyncTicks(10) expect(isPaneSpawnDeferred(PANE_KEY)).toBe(false) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts index 8e528db8b1fe..334411db2da2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts @@ -85,6 +85,7 @@ describe('wake through main in a layout-owner build', () => { // Main's start of the revealed pane is its wake: the window records none of its own. it('asks main to start the revealed pane alone, and records no wake itself', async () => { const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) const deps = buildPaneConnectionDeps(() => mockStoreState, { @@ -112,9 +113,11 @@ describe('wake through main in a layout-owner build', () => { const binding = connectPanePty(...args) await flushAsyncTicks() const onPtyExit = createdTransportOptions[0]?.onPtyExit + deps.isPtyShutdownPending.mockReturnValueOnce(true) if (typeof onPtyExit === 'function') { onPtyExit('tab-pty') } + settleDeferredPtyShutdownExits(['tab-pty'], 'committed') await flushAsyncTicks() // Hidden: the sleep holds until the pane is shown. expect(mocks.wake).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts index 12fceec49ae4..b9f3aa9eb47b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts @@ -360,7 +360,10 @@ export function registerPtyConnectionSleepingResumeBannerTests(): void { const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string) => void) | undefined + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') + deps.isPtyShutdownPending.mockReturnValueOnce(true) onPtyExit?.('fresh-pty') + settleDeferredPtyShutdownExits(['fresh-pty'], 'committed') await flushAsyncTicks(10) isVisibleRef.current = true binding.noteVisibilityResume() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index eda4aada2de2..2565965e0ab5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -31,7 +31,7 @@ export type PtyPaneStartup = { launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index f38e65b95240..5c9f741106a3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -144,9 +144,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { ? inspectRuntimeTerminalProcess(id, options) : window.api.pty.getForegroundProcess(id), confirmForegroundProcess: (id, options) => - isRemoteExecutionHostPtyId(id) - ? inspectRuntimeTerminalProcess(id, options) - : window.api.pty.confirmForegroundProcess(id), + inspectRuntimeTerminalProcess(id, { ...options, scanChildProcesses: true }), isRemotePtyId: isRemoteExecutionHostPtyId, getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null, publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts index 4e728854c73f..a2c6707b1cee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -90,7 +90,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { session.handledExitPtyId = null // Only genuine fresh, never-typed PTYs keep startup errors visible; dead reattached sessions must still close normally. session.spawnedFreshPtyId = null - // Suppressed hidden hibernation exits still owe a wake; retain the exact PTY/record so a later session cannot be adopted. + // Reversible hidden hibernation exits still owe a wake; retain the exact PTY/record so a later session cannot be adopted. session.hibernatedWakeTarget = null session.wakeHibernatedAgentPane = null // Latch mobile wakes between record write and kill-exit arming; mobile never reveals the desktop to retry the lost edge. @@ -154,7 +154,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { session.onExit = ( ptyId: string, exitCode = 0, - opts: { preserveRendererBinding?: boolean } = {} + opts: { preserveRendererBinding?: boolean | 'closing' } = {} ): void => { if (session.handledExitPtyId === ptyId) { return @@ -172,9 +172,10 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { return } const isUnverifiedExit = !isProvenProcessExit(exitCode) + const committedExit = consumeCommittedPtyShutdownExit(ptyId, session.runtimeEnvironmentId) + const reversibleExit = opts.preserveRendererBinding === true || committedExit === true const preserveRendererBinding = - opts.preserveRendererBinding === true || - consumeCommittedPtyShutdownExit(ptyId, session.runtimeEnvironmentId) + reversibleExit || opts.preserveRendererBinding === 'closing' || committedExit === 'closing' session.resetRendererOrderedSeqForPtyExit(ptyId) const currentPaneTransport = session.deps.paneTransportsRef.current.get(session.pane.id) if (currentPaneTransport && currentPaneTransport !== session.transport) { @@ -225,8 +226,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { } // Consume intentional-exit suppression before split close logic so in-place restart keeps its pane. if (isSuppressedExit) { - // Why: the action that suppressed the exit owns whether the leaf binding - // is a wake hint or should be discarded; runtime cleanup above is enough. + // Why: retention alone grants no wake; only the committed reversible owner supplies that authority. session.manager.setPaneGpuRendering(session.pane.id, true) if (replacedByRestart) { // Nothing else will replace this process; the pane takes it back when shown. @@ -234,7 +234,11 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { return } const sleepingRecordEntry = session.getSleepingRecordForPane(useAppStore.getState()) - if (sleepingRecordEntry && noteArmsHibernatedPaneWake(sleepingRecordEntry.record)) { + if ( + reversibleExit && + sleepingRecordEntry && + noteArmsHibernatedPaneWake(sleepingRecordEntry.record) + ) { // Disarm input-eating modes on the dead frozen TUI, then arm its reveal wake. session.writeInputModeGround(POST_REPLAY_MODE_RESET) session.hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } diff --git a/src/renderer/src/components/terminal-pane/pty-data-delivery.ts b/src/renderer/src/components/terminal-pane/pty-data-delivery.ts new file mode 100644 index 000000000000..252f70add826 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-data-delivery.ts @@ -0,0 +1,72 @@ +import type { PtyDataMeta } from './pty-dispatcher' +import { deliverPtyDataWithDeferredAck } from './terminal-pty-ack-gate' +import { bufferPreHandlerPtyData } from './pty-pre-handler-buffer' +import { recordPtyDataReceived } from './terminal-delivery-watchdog' +import { + bufferPtyShutdownData, + isPtyDataHandlerShutdownPending, + ptyDataHandlers, + ptyDataSidecars +} from './pty-shutdown-data-suspension' + +export function handleDispatchedPtyData(payload: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + gapEscapeState?: string +}): void { + let meta: PtyDataMeta | undefined + if (typeof payload.seq === 'number') { + meta ??= {} + meta.seq = payload.seq + } + if (typeof payload.rawLength === 'number') { + meta ??= {} + meta.rawLength = payload.rawLength + } + if (payload.transformed === true) { + meta ??= {} + meta.transformed = true + } + if (payload.background === true) { + meta ??= {} + meta.background = true + } + if (payload.droppedOutput === true) { + meta ??= {} + meta.droppedOutput = true + } + if (typeof payload.gapEscapeState === 'string' && payload.gapEscapeState.length > 0) { + meta ??= {} + meta.gapEscapeState = payload.gapEscapeState + } + const chars = payload.rawLength ?? payload.data.length + const dispatch = (): void => { + if (isPtyDataHandlerShutdownPending(payload.id)) { + // Why: teardown output is speculative until the owner verifies sleep; retain it so a failed attempt resumes without losing terminal data. + bufferPtyShutdownData(payload.id, payload.data, meta) + return + } + const handler = ptyDataHandlers.get(payload.id) + if (handler) { + handler(payload.data, meta) + } else { + bufferPreHandlerPtyData(payload.id, payload.data, meta) + } + const sidecars = ptyDataSidecars.get(payload.id) + if (sidecars && sidecars.size > 0) { + // Why: snapshot before iterating — watchers often unsubscribe (or subscribe siblings) mid-iteration, and mutating the live Set would skip or double-fire. + const snapshot = Array.from(sidecars) + for (const watcher of snapshot) { + watcher(payload.data) + } + } + } + recordPtyDataReceived(payload.id, chars) + // Why deferred: main budgets by bytes PARSED not received; ACK fires when xterm consumes, and undelivered chunks settle at return so no PTY stays backpressured. + deliverPtyDataWithDeferredAck(payload.id, chars, dispatch) +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index 4c55bde469cc..29774152e80f 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -351,8 +351,8 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { expect(() => exitDispatcherCallback?.({ id: 'pty-throwing-exit', code: 9 })).toThrow( 'primary exit failed' ) - expect(firstSidecar).toHaveBeenCalledWith(9, { hadPrimary: true }) - expect(secondSidecar).toHaveBeenCalledWith(9, { hadPrimary: true }) + expect(firstSidecar).toHaveBeenCalledWith(9, { hadPrimary: true, exitOptions: {} }) + expect(secondSidecar).toHaveBeenCalledWith(9, { hadPrimary: true, exitOptions: {} }) expect(() => exitDispatcherCallback?.({ id: 'pty-throwing-exit', code: 10 })).not.toThrow() expect(primary).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index ee7e8c6a8e90..ecde45fbd8ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -1,36 +1,34 @@ +import { handleDispatchedPtyData } from './pty-data-delivery' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' import { clearProcessedPtyCharTotal, - deliverPtyDataWithDeferredAck, exposeE2eTerminalPtyAckGate, getProcessedPtyCharTotals } from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { - bufferPreHandlerPtyData, clearPreHandlerPtyState, drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' -import { deliverPtyExitToHandlers } from './pty-exit-delivery' +import { + deliverPtyExitToHandlers, + type PtyExitDeliveryContext, + type PtyExitOptions +} from './pty-exit-delivery' import { clearReceivedPtyCharTotal, isPtyPushDeliveryBlackholed, - recordPtyDataReceived, startTerminalDeliveryWatchdog } from './terminal-delivery-watchdog' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' import { installTerminalFreezeReport } from './terminal-freeze-report' import { - bufferPtyShutdownData, bufferPtyShutdownReplayData, - isPtyDataHandlerShutdownPending, ptyDataHandlers, - ptyDataSidecars, ptyExitHandlers, ptyReplayHandlers } from './pty-shutdown-data-suspension' -import { markCommittedPtyShutdowns } from './pty-shutdown-exit-deferral' export { ptyDataHandlers, @@ -62,7 +60,7 @@ export type PtyDataMeta = { /** Per-PTY replay handlers on a dedicated pty:replay channel so the renderer can engage the replay guard and suppress xterm auto-replies. */ const ptyExitSidecars = new Map< string, - Set<(code: number, context: { hadPrimary: boolean }) => void> + Set<(code: number, context: PtyExitDeliveryContext) => void> >() export const ptyWriteUnavailableHandlers = new Map void>() let ptyDispatcherAttached = false @@ -110,68 +108,6 @@ function attachPtyPushListeners(): void { attachPtySecondaryPushListeners(unsubscribes) } -function handleDispatchedPtyData(payload: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - gapEscapeState?: string -}): void { - let meta: PtyDataMeta | undefined - if (typeof payload.seq === 'number') { - meta ??= {} - meta.seq = payload.seq - } - if (typeof payload.rawLength === 'number') { - meta ??= {} - meta.rawLength = payload.rawLength - } - if (payload.transformed === true) { - meta ??= {} - meta.transformed = true - } - if (payload.background === true) { - meta ??= {} - meta.background = true - } - if (payload.droppedOutput === true) { - meta ??= {} - meta.droppedOutput = true - } - if (typeof payload.gapEscapeState === 'string' && payload.gapEscapeState.length > 0) { - meta ??= {} - meta.gapEscapeState = payload.gapEscapeState - } - const chars = payload.rawLength ?? payload.data.length - const dispatch = (): void => { - if (isPtyDataHandlerShutdownPending(payload.id)) { - // Why: teardown output is speculative until the owner verifies sleep; retain it so a failed attempt resumes without losing terminal data. - bufferPtyShutdownData(payload.id, payload.data, meta) - return - } - const handler = ptyDataHandlers.get(payload.id) - if (handler) { - handler(payload.data, meta) - } else { - bufferPreHandlerPtyData(payload.id, payload.data, meta) - } - const sidecars = ptyDataSidecars.get(payload.id) - if (sidecars && sidecars.size > 0) { - // Why: snapshot before iterating — watchers often unsubscribe (or subscribe siblings) mid-iteration, and mutating the live Set would skip or double-fire. - const snapshot = Array.from(sidecars) - for (const watcher of snapshot) { - watcher(payload.data) - } - } - } - recordPtyDataReceived(payload.id, chars) - // Why deferred: main budgets by bytes PARSED not received; ACK fires when xterm consumes, and undelivered chunks settle at return so no PTY stays backpressured. - deliverPtyDataWithDeferredAck(payload.id, chars, dispatch) -} - function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { const unsubscribeWriteUnavailable = window.api.pty.onWriteUnavailable?.((payload) => { ptyWriteUnavailableHandlers.get(payload.id)?.() @@ -189,10 +125,6 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { ) unsubscribes.push( window.api.pty.onExit((payload) => { - if (payload.preserveRendererBinding === true) { - // Why: host-initiated remote sleep has no requester transaction in this renderer; classify its ordered exit before pane cleanup runs. - markCommittedPtyShutdowns([payload.id]) - } // Why: main drops its accounting on exit; drop totals too so a reused id restarts at zero on both sides. clearProcessedPtyCharTotal(payload.id) clearReceivedPtyCharTotal(payload.id) @@ -208,6 +140,10 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { deliverPtyExitToHandlers({ ptyId: payload.id, code: payload.code, + ...(payload.preserveRendererBinding === true || + payload.preserveRendererBinding === 'closing' + ? { preserveRendererBinding: payload.preserveRendererBinding } + : {}), // Buffered exits carry their PTY lifetime because reused IDs can name a new shell (#16970). ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}), ...(payload.replacedByRestart === true ? { replacedByRestart: true } : {}), @@ -232,7 +168,7 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { export function subscribeToPtyExit( ptyId: string, - watcher: (code: number, context: { hadPrimary: boolean }) => void + watcher: (code: number, context: PtyExitDeliveryContext) => void ): () => void { ensurePtyDispatcher() let set = ptyExitSidecars.get(ptyId) @@ -268,7 +204,7 @@ const EAGER_BUFFER_MAX_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT /** Fence the spawned incarnation so recycled relay IDs cannot deliver the prior owner’s exit to a fresh background agent. */ export function registerEagerPtyBuffer( ptyId: string, - onExit: (ptyId: string, code: number) => void, + onExit: (ptyId: string, code: number, options?: PtyExitOptions) => void, incarnationId?: string ): EagerPtyHandle { ensurePtyDispatcher() @@ -294,7 +230,7 @@ export function registerEagerPtyBuffer( head = 0 } } - const exitHandler = (code: number): void => { + const exitHandler = (code: number, options?: PtyExitOptions): void => { // Shell died before attach; identity-guard so we never evict a handler a transport re-registered for this id (#7894 detach/attach race). if (ptyDataHandlers.get(ptyId) === dataHandler) { ptyDataHandlers.delete(ptyId) @@ -302,7 +238,11 @@ export function registerEagerPtyBuffer( } ptyExitHandlers.delete(ptyId) eagerPtyHandles.delete(ptyId) - onExit(ptyId, code) + if (options) { + onExit(ptyId, code, options) + } else { + onExit(ptyId, code) + } } ptyDataHandlers.set(ptyId, dataHandler) diff --git a/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts b/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts index ae74d4e0223f..c82ba01954e3 100644 --- a/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts +++ b/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts @@ -5,6 +5,9 @@ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +export type PtyExitOptions = { preserveRendererBinding?: boolean | 'closing' } +export type PtyExitDeliveryContext = { hadPrimary: boolean; exitOptions?: PtyExitOptions } + type PtyExitDelivery = { ptyId: string code: number @@ -12,8 +15,9 @@ type PtyExitDelivery = { incarnationId?: string /** Main stopped this PTY so a new process could take its pane. */ replacedByRestart?: boolean - primary?: (code: number) => void - sidecars: readonly ((code: number, context: { hadPrimary: boolean }) => void)[] + preserveRendererBinding?: PtyExitOptions['preserveRendererBinding'] + primary?: (code: number, options?: PtyExitOptions) => void + sidecars: readonly ((code: number, context: PtyExitDeliveryContext) => void)[] } // Scope labels to synchronous delivery so no stale record can misclassify a later same-ID exit. @@ -39,13 +43,21 @@ export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void { } function deliverClassifiedPtyExit(delivery: PtyExitDelivery): void { + // The parked observer resolves genuine authority onto the same buffered event before handoff. + const exitOptions: PtyExitOptions = delivery.preserveRendererBinding + ? { preserveRendererBinding: delivery.preserveRendererBinding } + : {} let firstError: unknown let hasError = false try { if (delivery.primary) { clearPreHandlerPtyState(delivery.ptyId) try { - delivery.primary(delivery.code) + if (exitOptions.preserveRendererBinding) { + delivery.primary(delivery.code, exitOptions) + } else { + delivery.primary(delivery.code) + } } finally { // Exit ownership is final despite cleanup throws; duplicates cannot become pre-handler state for a later mount. consumePreHandlerPtyState(delivery.ptyId) @@ -54,7 +66,7 @@ function deliverClassifiedPtyExit(delivery: PtyExitDelivery): void { // Discard restart exits and old bindings so mid-restart mounts attach by pane to the replacement, not the dead PTY. discardPreHandlerPtyState(delivery.ptyId) } else { - bufferPreHandlerPtyExit(delivery.ptyId, delivery.code, delivery.incarnationId) + bufferPreHandlerPtyExit(delivery.ptyId, delivery.code, delivery.incarnationId, exitOptions) } } catch (error) { firstError = error @@ -63,7 +75,7 @@ function deliverClassifiedPtyExit(delivery: PtyExitDelivery): void { for (const sidecar of delivery.sidecars) { try { - sidecar(delivery.code, { hadPrimary: delivery.primary !== undefined }) + sidecar(delivery.code, { hadPrimary: delivery.primary !== undefined, exitOptions }) } catch (error) { if (!hasError) { firstError = error diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts index 9f1f628ec48c..9aa9a2c8d68c 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -1,4 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { deliverPtyExitToHandlers } from './pty-exit-delivery' +import { + markCommittedPtyShutdowns, + consumeCommittedPtyShutdownExit +} from './pty-shutdown-exit-deferral' import { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, @@ -298,15 +303,33 @@ describe('pre-handler PTY buffer', () => { }) it('replaces a duplicate exit for the same lifetime instead of crowding out another lifetime', () => { - bufferPreHandlerPtyExit(RECYCLED_PTY_ID, 1, FRESH_INCARNATION_ID) + markCommittedPtyShutdowns([RECYCLED_PTY_ID]) + deliverPtyExitToHandlers({ + ptyId: RECYCLED_PTY_ID, + code: 1, + incarnationId: FRESH_INCARNATION_ID, + preserveRendererBinding: 'closing', + sidecars: [ + (_code, context) => { + const disposition = consumeCommittedPtyShutdownExit(RECYCLED_PTY_ID) + expect(context.hadPrimary).toBe(false) + if (disposition && context.exitOptions) { + context.exitOptions.preserveRendererBinding = disposition + } + } + ] + }) + expect(consumeCommittedPtyShutdownExit(RECYCLED_PTY_ID)).toBe(false) bufferPreHandlerPtyExit(RECYCLED_PTY_ID, 9, FRESH_INCARNATION_ID) - bufferPreHandlerPtyExit(RECYCLED_PTY_ID, 0, PRIOR_INCARNATION_ID) + bufferPreHandlerPtyExit(RECYCLED_PTY_ID, 0, PRIOR_INCARNATION_ID, { + preserveRendererBinding: 'closing' + }) discardPreHandlerPtyExitFromForeignIncarnation(RECYCLED_PTY_ID, FRESH_INCARNATION_ID) const exit = vi.fn() drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(9) + expect(exit).toHaveBeenCalledWith(9, { preserveRendererBinding: true }) }) // Reads enforce lifetime filtering even for eager background launches that never invoke explicit discard. diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index ac4b7b3123a1..3c2d6f118b8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -1,6 +1,7 @@ import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' import { clampUtf8Tail } from './pty-eager-buffer-clamp' import type { PtyDataMeta } from './pty-dispatcher' +import type { PtyExitOptions } from './pty-exit-delivery' type BufferedPreHandlerPtyData = { data: string @@ -17,6 +18,7 @@ type BufferedPreHandlerPtyState = { } type BufferedPreHandlerPtyExit = { + options?: PtyExitOptions code: number sequence: number /** Which lifetime of `ptyId` died. Absent when the emitting host predates the field. */ @@ -181,13 +183,15 @@ export function replayPreHandlerPtyData(ptyId: string, observer: (data: string) export function bufferPreHandlerPtyExit( ptyId: string, code: number, - incarnationId?: unknown + incarnationId?: unknown, + options?: PtyExitOptions ): void { if (consumedPreHandlerPtyExits.has(ptyId) || discardedPreHandlerPtyStates.has(ptyId)) { return } evictOldestPtyIfAtCap(preHandlerPtyExit, ptyId, PRE_HANDLER_PTY_EXIT_MAX_PTYS) const exit: BufferedPreHandlerPtyExit = { + ...(options ? { options } : {}), code, sequence: nextPreHandlerPtySequence(), // Record only a well-formed incarnation; a malformed one must not become evidence. @@ -201,6 +205,13 @@ export function bufferPreHandlerPtyExit( // Replace duplicates within their lifetime; unnamed exits keep the single legacy slot. const sameLifetime = exits.findIndex((entry) => entry.incarnationId === exit.incarnationId) if (sameLifetime !== -1) { + const previous = exits[sameLifetime].options?.preserveRendererBinding + if (previous) { + exit.options ??= {} + if (previous === true || !exit.options.preserveRendererBinding) { + exit.options.preserveRendererBinding = previous + } + } exits[sameLifetime] = exit return } @@ -286,7 +297,7 @@ export function hasPreHandlerPtyExit(ptyId: string, incarnationId?: unknown): bo export function drainPreHandlerPtyExit( ptyId: string, - handler: (code: number) => void, + handler: (code: number, options?: PtyExitOptions) => void, incarnationId?: unknown ): void { // Select the newest admissible lifetime record to preserve last-write-wins behavior. @@ -301,7 +312,11 @@ export function drainPreHandlerPtyExit( } preHandlerPtyExit.delete(ptyId) try { - handler(exit.code) + if (exit.options?.preserveRendererBinding) { + handler(exit.code, exit.options) + } else { + handler(exit.code) + } } finally { // Draining transfers exit ownership despite handler throws; duplicates cannot become new pre-handler events. consumePreHandlerPtyState(ptyId) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 508ea88b2bec..f0e0b571a907 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -1,3 +1,4 @@ +import type { PtyExitOptions } from './pty-exit-delivery' import { clearPreHandlerPtyState, drainPreHandlerPtyData } from './pty-pre-handler-buffer' import type { PtyDataMeta } from './pty-dispatcher' import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' @@ -5,7 +6,7 @@ import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutd export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() export const ptyReplayHandlers = new Map void>() -export const ptyExitHandlers = new Map void>() +export const ptyExitHandlers = new Map void>() export const ptyTeardownHandlers = new Map void>() export const ptyShutdownLifecycleHandlers = new Map< string, diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts index 6672a5e1ce88..15d5989c6a5f 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts @@ -38,6 +38,44 @@ it('consumes a committed late-exit guard only once', () => { expect(consumeCommittedPtyShutdownExit('pty-late-commit')).toBe(true) expect(consumeCommittedPtyShutdownExit('pty-late-commit')).toBe(false) + + markCommittedPtyShutdowns(['pty-closing'], 'closing') + expect(consumeCommittedPtyShutdownExit('pty-closing')).toBe('closing') + expect(consumeCommittedPtyShutdownExit('pty-closing')).toBe(false) + + markCommittedPtyShutdowns(['pty-overlap']) + markCommittedPtyShutdowns(['pty-overlap'], 'closing') + expect(consumeCommittedPtyShutdownExit('pty-overlap')).toBe(true) + expect(consumeCommittedPtyShutdownExit('pty-overlap')).toBe(false) + + markCommittedPtyShutdowns(['pty-overlap'], 'closing') + markCommittedPtyShutdowns(['pty-overlap']) + expect(consumeCommittedPtyShutdownExit('pty-overlap')).toBe(true) + expect(consumeCommittedPtyShutdownExit('pty-overlap')).toBe(false) +}) + +it('bounds closing markers by the same count and expiry without reviving old wake authority', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-07-21T00:00:00Z')) + const ptyIds = Array.from({ length: 513 }, (_, index) => `pty-closing-bounded-${index}`) + markCommittedPtyShutdowns(ptyIds, 'closing') + expect(consumeCommittedPtyShutdownExit('pty-closing-bounded-0')).toBe(false) + for (const ptyId of ptyIds.slice(1)) { + expect(consumeCommittedPtyShutdownExit(ptyId)).toBe('closing') + expect(consumeCommittedPtyShutdownExit(ptyId)).toBe(false) + } + + markCommittedPtyShutdowns(['pty-expired-reversible']) + markCommittedPtyShutdowns(['pty-expired-closing'], 'closing') + vi.advanceTimersByTime(30_001) + expect(consumeCommittedPtyShutdownExit('pty-expired-closing')).toBe(false) + markCommittedPtyShutdowns(['pty-expired-reversible'], 'closing') + expect(consumeCommittedPtyShutdownExit('pty-expired-reversible')).toBe('closing') + expect(consumeCommittedPtyShutdownExit('pty-expired-reversible')).toBe(false) + } finally { + vi.useRealTimers() + } }) it('clears a committed guard when its deferred exit is replayed', () => { @@ -69,7 +107,9 @@ it('preserves another client binding through host sleep until the host reports w applyHostWorktreeTerminalSleepState('env-a', { ...started, phase: 'committed' }) expect(callback).toHaveBeenCalledWith('committed') + markCommittedPtyShutdowns([remotePtyId], 'closing') expect(consumeCommittedPtyShutdownExit(remotePtyId, 'env-a')).toBe(true) + expect(consumeCommittedPtyShutdownExit(remotePtyId, 'env-a')).toBe(false) expect(consumeCommittedPtyShutdownExit(remotePtyId, 'env-b')).toBe(false) applyHostWorktreeTerminalSleepState('env-a', { ...started, phase: 'woken' }) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts index 707d3f89cb78..0ae70371487f 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts @@ -12,7 +12,11 @@ const deferredExitCallbacksByPtyId = new Map< string, Set<(settlement: PtyShutdownSettlement) => void> >() -const committedExitExpiresAtByPtyId = new Map() +type PtyShutdownExitDisposition = true | 'closing' +const committedExitByPtyId = new Map< + string, + { expiresAt: number; disposition: PtyShutdownExitDisposition } +>() const committedPendingSettlements = new Set() const hostSleepDispositionByPtyId = new Map< string, @@ -31,17 +35,17 @@ const COMMITTED_EXIT_MAX = 512 const HOST_SLEEP_DISPOSITION_GRACE_MS = 30_000 function pruneCommittedExitGuards(now = Date.now()): void { - for (const [ptyId, expiresAt] of committedExitExpiresAtByPtyId) { + for (const [ptyId, { expiresAt }] of committedExitByPtyId) { if (expiresAt <= now) { - committedExitExpiresAtByPtyId.delete(ptyId) + committedExitByPtyId.delete(ptyId) } } - while (committedExitExpiresAtByPtyId.size > COMMITTED_EXIT_MAX) { - const oldestPtyId = committedExitExpiresAtByPtyId.keys().next().value + while (committedExitByPtyId.size > COMMITTED_EXIT_MAX) { + const oldestPtyId = committedExitByPtyId.keys().next().value if (typeof oldestPtyId !== 'string') { break } - committedExitExpiresAtByPtyId.delete(oldestPtyId) + committedExitByPtyId.delete(oldestPtyId) } } @@ -53,6 +57,17 @@ function pruneHostSleepDispositions(now = Date.now()): void { } } +function currentHostSleepDisposition( + key: string +): NonNullable> | undefined { + const disposition = hostSleepDispositionByPtyId.get(key) + if (disposition && disposition.expiresAt <= Date.now()) { + expireHostSleepDisposition(key, disposition) + return undefined + } + return disposition +} + function installHostSleepDisposition( key: string, disposition: Omit>, 'expiryTimer'> @@ -96,11 +111,19 @@ function expireHostSleepDisposition( } } -export function markCommittedPtyShutdowns(ptyIds: readonly string[]): void { +export function markCommittedPtyShutdowns( + ptyIds: readonly string[], + disposition: PtyShutdownExitDisposition = true +): void { + pruneCommittedExitGuards() const expiresAt = Date.now() + COMMITTED_EXIT_GRACE_MS for (const ptyId of ptyIds) { - committedExitExpiresAtByPtyId.delete(ptyId) - committedExitExpiresAtByPtyId.set(ptyId, expiresAt) + const existing = committedExitByPtyId.get(ptyId) + committedExitByPtyId.delete(ptyId) + committedExitByPtyId.set(ptyId, { + expiresAt, + disposition: existing?.disposition === true ? true : disposition + }) } pruneCommittedExitGuards() } @@ -124,13 +147,14 @@ export function clearCommittedPtyShutdownSettlements(ptyIds: readonly string[]): export function consumeCommittedPtyShutdownExit( ptyId: string, runtimeEnvironmentId?: string | null -): boolean { +): false | PtyShutdownExitDisposition { pruneCommittedExitGuards() - pruneHostSleepDispositions() + const committedExit = committedExitByPtyId.get(ptyId) + committedExitByPtyId.delete(ptyId) if (runtimeEnvironmentId) { const hostKey = hostSleepPtyKey(runtimeEnvironmentId, ptyId) - if (hostSleepDispositionByPtyId.get(hostKey)?.phase === 'committed') { - const disposition = hostSleepDispositionByPtyId.get(hostKey) + const disposition = currentHostSleepDisposition(hostKey) + if (disposition?.phase === 'committed') { if (disposition?.expiryTimer !== undefined) { clearTimeout(disposition.expiryTimer) } @@ -138,22 +162,16 @@ export function consumeCommittedPtyShutdownExit( return true } } - if (!committedExitExpiresAtByPtyId.has(ptyId)) { - return false - } - committedExitExpiresAtByPtyId.delete(ptyId) - return true + return committedExit?.disposition ?? false } export function isHostPtySleepPending( ptyId: string, runtimeEnvironmentId?: string | null ): boolean { - pruneHostSleepDispositions() return Boolean( runtimeEnvironmentId && - hostSleepDispositionByPtyId.get(hostSleepPtyKey(runtimeEnvironmentId, ptyId))?.phase === - 'pending' + currentHostSleepDisposition(hostSleepPtyKey(runtimeEnvironmentId, ptyId))?.phase === 'pending' ) } @@ -280,7 +298,7 @@ export function settleDeferredPtyShutdownExits( deferredExitCallbacksByPtyId.delete(ptyId) if (settlement === 'committed') { // Why: replay classifies the old exit; retaining its guard could misclassify a same-ID session woken immediately afterward. - committedExitExpiresAtByPtyId.delete(ptyId) + committedExitByPtyId.delete(ptyId) } for (const callback of callbacks) { try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts index 2a87c9199399..a63ff8d56aee 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts @@ -103,7 +103,7 @@ describe('createIpcPtyTransport', () => { onExit?.({ id: 'pty-restored', code: 0 }) expect(eagerExit).not.toHaveBeenCalled() - expect(sidecarExit).toHaveBeenCalledWith(0, { hadPrimary: true }) + expect(sidecarExit).toHaveBeenCalledWith(0, { hadPrimary: true, exitOptions: {} }) }) it('bounds the eager buffer to its cap and keeps the most recent output', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts index 2c77b0a312fd..d1b1966bcb36 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts @@ -129,15 +129,17 @@ describe('createIpcPtyTransport', () => { expect(onPtyExit).toHaveBeenCalledWith('pty-1', -1) }) - it('marks a host reversible-stop exit before delivering it to the pane', async () => { + it('delivers a host reversible-stop purpose to the pane callbacks', async () => { const { createIpcPtyTransport } = await import('./pty-transport') - const { consumeCommittedPtyShutdownExit } = await import('./pty-shutdown-exit-deferral') - const transport = createIpcPtyTransport() - await transport.connect({ url: '', callbacks: {} }) + const onExitCallback = vi.fn() + const onPtyExit = vi.fn() + const transport = createIpcPtyTransport({ onPtyExit }) + await transport.connect({ url: '', callbacks: { onExit: onExitCallback } }) onExit?.({ id: 'pty-1', code: 0, preserveRendererBinding: true }) - expect(consumeCommittedPtyShutdownExit('pty-1')).toBe(true) + expect(onExitCallback).toHaveBeenCalledExactlyOnceWith(0, { preserveRendererBinding: true }) + expect(onPtyExit).toHaveBeenCalledExactlyOnceWith('pty-1', 0, { preserveRendererBinding: true }) }) it('restores data handlers when an intentional shutdown fails before exit', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1c25be3089f8..44a46c54c9e7 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { PtyExitOptions } from './pty-exit-delivery' import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { @@ -138,7 +139,7 @@ type PtyCallbacks = { onStatus?: (shell: string) => void onError?: (message: string, errors?: string[]) => void onErrorCleared?: (message: string) => void - onExit?: (code: number) => void + onExit?: (code: number, options?: PtyExitOptions) => void onWriteUnavailable?: () => void onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void /** `dropsReported`: the host sends onPausedOutputDropped, so a pause without one lost nothing. */ @@ -190,7 +191,7 @@ export type PtyTransport = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -292,7 +293,7 @@ export type IpcPtyTransportOptions = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery agentArgsOverride?: string | null @@ -314,7 +315,7 @@ export type IpcPtyTransportOptions = { terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors telemetry?: EventProps<'agent_started'> - onPtyExit?: (ptyId: string, exitCode?: number) => void + onPtyExit?: (ptyId: string, exitCode?: number, options?: PtyExitOptions) => void onTitleChange?: (title: string, rawTitle: string) => void onPtySpawn?: (ptyId: string) => void /** Current surface may retain a late disposed spawn for its successor; absent/false authorizes kill (disposed-spawn-retention.ts). */ diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 31056fadf744..99b7b2c6bd47 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' import { isPathInsideWorktree, toWorktreeRelativePath } from '../../../../shared/terminal-links' @@ -83,6 +84,7 @@ export function openDetectedFilePath( column: number | null, deps: TerminalFileOpenDeps ): void { + noteViewerNavigation() const { openWithSystemDefault = false, runtimeEnvironmentId } = deps let { worktreeId, worktreePath } = deps let mappedFilePath = mapTerminalFilePath( diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index dbdb387e5bd5..3a416a2de0dd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { ILink, ILinkProvider, Terminal } from '@xterm/xterm' import type { AppState } from '@/store' import { useAppStore } from '@/store' @@ -145,6 +146,7 @@ export function focusRendererTerminalHandle( return false } + noteViewerNavigation() revealWorkspaceSurface(store, target.worktreeId) store.setActiveTabType('terminal', target.worktreeId) if (target.leafId) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts index 01c4b8c89862..4700454b88f5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts @@ -39,9 +39,13 @@ export function applyTerminalPaneCloseRequest(args: { } if (args.detail.preservePty) { if (args.detail.retireSurface) { - args.manager.retirePanePreservingPty(paneRuntimeId) + if (!args.manager.retirePanePreservingPty(paneRuntimeId)) { + return 'ignored' + } } else { - args.manager.detachPaneForExternalMove(paneRuntimeId) + if (!args.manager.detachPaneForExternalMove(paneRuntimeId)) { + return 'ignored' + } } } else { args.manager.closePane(paneRuntimeId) @@ -78,13 +82,14 @@ export function retireMountedTerminalPaneSurface(args: { ) => void clearExitedPanePtyLayoutBindingForLeaf?: (leafId: string, exitedPtyId: string) => void clearTabPtyId: (tabId: string, ptyId: string) => void + permanent?: boolean transport?: { detach?: (options?: { preserveExitObserver?: boolean }) => void destroy?: () => void } }): void { args.retireAgentPaneAuthority(args.paneKey, { - preserveSleepingAgentSession: true + preserveSleepingAgentSession: !args.permanent }) if (args.ptyId) { if (args.clearExitedPanePtyLayoutBindingForLeaf) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts index 8a9080aae1cc..7c17e572d55f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts @@ -24,6 +24,7 @@ export function cleanupTerminalPaneMount(args: { expandedStyleSnapshots: Map unregisterRuntimeTab: () => void cancelResize: () => void + permanent?: boolean }): void { const { manager, @@ -41,11 +42,13 @@ export function cleanupTerminalPaneMount(args: { const tabRemainsInWorktree = Boolean( currentWorktreeTabs?.some((candidate) => candidate.id === tabId) ) - if (!tabRemainsInWorktree) { + if (args.permanent || !tabRemainsInWorktree) { cancelQueuedTerminalPaneSplitRequests(tabId, worktreeId) } - // Global ownership permits handoff across the bucket gap before a rehomed surface mounts, including deferred splits without PTY IDs. - const tabStillExists = isTerminalTabPresent(currentStore, tabId) + // Handoff retention is deliberately broader: a tab move removes the old + // worktree bucket before the replacement surface mounts, so use the shared + // global ownership check to let an ID-less deferred split survive a rehome. + const tabStillExists = !args.permanent && isTerminalTabPresent(currentStore, tabId) unregisterRuntimeTab() cancelResize() restoreExpandedLayoutFrom(expandedStyleSnapshots) @@ -68,24 +71,27 @@ export function cleanupTerminalPaneMount(args: { disposeAll(refs.imeCompositionDisposablesRef.current) disposeAll(refs.imeNativeTextForwarderDisposablesRef.current) - captureParkedTerminalPaneCandidates( - tabId, - worktreeId, - manager.getPanes().map((pane) => { - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - const binding = panePtyBindingsRef.current.get(pane.id) as - | { isUntouchedFreshSpawnPty?: (candidate: string) => boolean } - | undefined - return { - ptyId, - workspaceOwner: captureNotificationTransportOwner(paneTransportsRef.current.get(pane.id)), - paneId: pane.id, - leafId: pane.leafId, - drivesTabTitle: manager.getActivePane()?.id === pane.id, - untouchedFreshSpawn: ptyId !== null && (binding?.isUntouchedFreshSpawnPty?.(ptyId) ?? false) - } - }) - ) + if (tabStillExists) { + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((pane) => { + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const binding = panePtyBindingsRef.current.get(pane.id) as + | { isUntouchedFreshSpawnPty?: (candidate: string) => boolean } + | undefined + return { + ptyId, + workspaceOwner: captureNotificationTransportOwner(paneTransportsRef.current.get(pane.id)), + paneId: pane.id, + leafId: pane.leafId, + drivesTabTitle: manager.getActivePane()?.id === pane.id, + untouchedFreshSpawn: + ptyId !== null && (binding?.isUntouchedFreshSpawnPty?.(ptyId) ?? false) + } + }) + ) + } for (const [paneId, transport] of paneTransportsRef.current) { const ptyId = transport.getPtyId() const deferredSplitHandoff = deferredSplitHandoffs.get(paneId) @@ -99,6 +105,7 @@ export function cleanupTerminalPaneMount(args: { deferredSplitHandoffs.delete(paneId) } if ( + args.permanent || shouldDetachPaneTransportOnUnmount({ tabStillExists, tabId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts index d9baf8cfdd73..de7b89b5ff9d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts @@ -13,6 +13,7 @@ import type { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-so import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state' export type TerminalPaneMountContext = { + getOwnedManager?: () => PaneManager | null deps: UseTerminalPaneLifecycleDeps refs: TerminalPaneLifecycleRefs ptyDeps: PtyConnectionDeps @@ -54,5 +55,5 @@ export type PaneCreatedHandlerContext = TerminalPaneManagerOptionsContext export type PaneClosedHandlerContext = TerminalPaneMountContext & { paneId: number - closedPane?: { leafId: string; reason?: 'close' | 'detach' | 'retire' } + closedPane?: { leafId: string; reason?: 'close' | 'detach' | 'retire' | 'remove' } } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index 223d441afefe..6ff1cb41a836 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -1,3 +1,4 @@ +import { registerTerminalPaneMount } from './terminal-pane-mount-registration' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' @@ -25,7 +26,6 @@ import type { PtyConnectionDeps } from './pty-connection-types' import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types' import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-refs' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' import { normalizeTerminalLayoutSnapshot } from './layout-serialization' import { applyTerminalAppearance } from './terminal-appearance' import { terminalLinkClickBehaviorFor } from './terminal-link-click-behavior' @@ -52,6 +52,8 @@ export type TerminalPaneMountPreparation = { ptyDeps: PtyConnectionDeps deferredSplitHandoffs: Map unregisterRuntimeTab: () => void + setOwnedManager: (manager: PaneManager) => void + getOwnedManager: () => PaneManager | null fileOpenLinkHint: string getPaneLinkCwd: (paneId: number) => string getUrlOpenLinkHint: (paneId: number) => string @@ -75,7 +77,8 @@ export type TerminalPaneMountPreparation = { export function prepareTerminalPaneMount( deps: UseTerminalPaneLifecycleDeps, refs: TerminalPaneLifecycleRefs, - mountFollowsTerminalPark: boolean + mountFollowsTerminalPark: boolean, + onEmpty: (manager: PaneManager) => void ): TerminalPaneMountPreparation | null { const { containerRef, expandedStyleSnapshotRef, paneTransportsRef, panePtyBindingsRef } = deps const container = containerRef.current @@ -232,14 +235,10 @@ export function prepareTerminalPaneMount( restoredPtyIdByLeafId: deps.initialLayoutRef.current.ptyIdsByLeafId ?? {}, deferredSplitHandoffs }) - const unregisterRuntimeTab = registerRuntimeTerminalTab({ - tabId: deps.tabId, - worktreeId: deps.worktreeId, - getManager: () => deps.managerRef.current, - getContainer: () => deps.containerRef.current, - getPtyIdForPane: (paneId) => paneTransports.get(paneId)?.getPtyId() ?? null, - getTabWideAgentHintLeafId: deps.getTabWideAgentHintLeafId - }) + const { unregisterRuntimeTab, setOwnedManager, getOwnedManager } = registerTerminalPaneMount( + { deps, refs, deferredSplitHandoffs }, + onEmpty + ) const fileOpenLinkHint = getTerminalFileOpenHint() const getUrlOpenLinkHint = (paneId: number): string => getTerminalUrlOpenHint({ @@ -263,6 +262,8 @@ export function prepareTerminalPaneMount( ptyDeps, deferredSplitHandoffs, unregisterRuntimeTab, + setOwnedManager, + getOwnedManager, fileOpenLinkHint, getPaneLinkCwd, getUrlOpenLinkHint, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts new file mode 100644 index 000000000000..8b2b183ad184 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts @@ -0,0 +1,76 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' +import { removeTerminalPresentation } from './terminal-presentation-retirement' +import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' + +/** The mount retains its actual manager even while the rendering ref is temporarily cleared. */ +export function registerTerminalPaneMount( + context: Pick, + onEmpty: (manager: PaneManager) => void +) { + const { deps } = context + let ownedManager: PaneManager | null = null + const unregisterRuntimeTab = registerRuntimeTerminalTab({ + retirePresentation: (original) => { + const manager = ownedManager + const pane = manager?.getPanes().find((candidate) => candidate.leafId === original.leafId) + const transport = pane ? deps.paneTransportsRef.current.get(pane.id) : null + if (transport?.getPtyId() && transport.getPtyId() !== original.ptyId) { + throw new Error('terminal_pane_owner_changed') + } + if ( + !pane && + [...deps.paneTransportsRef.current.values()].some( + (candidate) => !manager || candidate.getPtyId() === original.ptyId + ) + ) { + throw new Error('terminal_pane_owner_unavailable') + } + removeTerminalPresentation(original, (failures) => { + if (pane && manager) { + const structuralFailures: unknown[] = [] + try { + if (!manager.retirePanePreservingPty(pane.id, true, failures)) { + throw new Error('terminal_pane_close_failed') + } + } catch (error) { + structuralFailures.push(error) + } finally { + if (manager.getPanes().length === 0) { + try { + onEmpty(manager) + } catch (error) { + structuralFailures.push(error) + } finally { + if (deps.managerRef.current === manager) { + deps.managerRef.current = null + } + ownedManager = null + unregisterRuntimeTab() + } + } + } + if (structuralFailures.length === 1) { + throw structuralFailures[0] + } + if (structuralFailures.length > 1) { + throw new AggregateError(structuralFailures, 'terminal_tab_close_failed') + } + } + }) + }, + tabId: deps.tabId, + worktreeId: deps.worktreeId, + getManager: () => deps.managerRef.current, + getContainer: () => deps.containerRef.current, + getPtyIdForPane: (paneId) => deps.paneTransportsRef.current.get(paneId)?.getPtyId() ?? null, + getTabWideAgentHintLeafId: deps.getTabWideAgentHintLeafId + }) + return { + unregisterRuntimeTab, + getOwnedManager: () => ownedManager, + setOwnedManager: (manager: PaneManager) => { + ownedManager = manager + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts index 6bab19095db2..847b93ed1a7f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts @@ -17,7 +17,10 @@ import { import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' export function createTerminalPaneClosedHandler( - context: Omit + context: Pick< + PaneClosedHandlerContext, + 'deps' | 'refs' | 'deferredSplitHandoffs' | 'getOwnedManager' + > ): (paneId: number, closedPane?: PaneClosedHandlerContext['closedPane']) => void { return (paneId, closedPane) => { const { deps, refs } = context @@ -47,7 +50,8 @@ export function createTerminalPaneClosedHandler( } = deps onPtyRecoveryStateRef?.current?.(paneId, null) const isDetachedToTab = closedPane?.reason === 'detach' - const isRetiredSurface = closedPane?.reason === 'retire' + const permanent = closedPane?.reason === 'remove' + const isRetiredSurface = closedPane?.reason === 'retire' || permanent disposeMapEntry(refs.linkProviderDisposablesRef.current, paneId) disposeMapEntry(refs.terminalHandleLinkDisposablesRef.current, paneId) disposeMapEntry(refs.linkifierClickPrimingDisposablesRef.current, paneId) @@ -106,6 +110,7 @@ export function createTerminalPaneClosedHandler( syncPanePtyLayoutBindingForLeaf, clearExitedPanePtyLayoutBindingForLeaf, clearTabPtyId, + permanent, ...(transport ? { transport } : {}) }) } else if (leafId && !isDetachedToTab) { @@ -131,6 +136,8 @@ export function createTerminalPaneClosedHandler( } transport.destroy?.() } + } + if (!isRetiredSurface || permanent) { paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -151,8 +158,9 @@ export function createTerminalPaneClosedHandler( paneTitlesRef.current = next } setRenamingPaneId((current) => (current === paneId ? null : current)) - setPaneCount(managerRef.current?.getPanes().length ?? 0) - const activePane = managerRef.current?.getActivePane() + const manager = context.getOwnedManager?.() ?? managerRef.current + setPaneCount(manager?.getPanes().length ?? 0) + const activePane = manager?.getActivePane() if (activePane) { reportActiveRendererPtyForPane(paneTransportsRef.current, activePane.id) const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index 7401bdef3eca..9dc631d793a8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -5,7 +5,7 @@ import { useAppStore } from '@/store' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { subscribeToPtyExit } from './pty-dispatcher' -import { isPtyExitReplacedByRestart } from './pty-exit-delivery' +import { isPtyExitReplacedByRestart, type PtyExitDeliveryContext } from './pty-exit-delivery' import { consumePreHandlerPtyState, discardPreHandlerPtyState, @@ -55,9 +55,11 @@ export function startParkedPtyWatcher(args: { ) { return } - const handlePtyExit = (code: number, { hadPrimary }: { hadPrimary: boolean }): void => { + const handlePtyExit = (code: number, context: PtyExitDeliveryContext): void => { + const { hadPrimary } = context // Replacement waits for a future connect, but the dead process’s working title must clear now. useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) + const preserveRendererBinding = !hadPrimary && isSleepPreservedParkedPtyExit(ptyId, context) if (isPtyExitReplacedByRestart(ptyId)) { // Why: the pane lives on under its replacement PTY; only this watcher's subscription ends. entry.disposersByPtyId.get(ptyId)?.() @@ -70,7 +72,7 @@ export function startParkedPtyWatcher(args: { useAppStore.getState().markUnverifiedPtyLoss(tab.id) } // Detach removed the primary observer; this sidecar must preserve sleep state/layout and keep the buffered-exit tombstone against stale watcher re-registration. - if (!hadPrimary && isSleepPreservedParkedPtyExit(ptyId)) { + if (preserveRendererBinding) { entry.disposersByPtyId.get(ptyId)?.() entry.disposersByPtyId.delete(ptyId) return @@ -143,13 +145,21 @@ export function startParkedPtyWatcher(args: { }) } -// Shutdown, suppressed restart and committed sleep markers preserve state for wake/restart. Remote host-sleep dispositions never reach this sidecar, so no environment ID is needed. -function isSleepPreservedParkedPtyExit(ptyId: string): boolean { +// Retained exits preserve presentation only. Remote host-sleep dispositions never reach this sidecar, so no environment ID is needed. +function isSleepPreservedParkedPtyExit(ptyId: string, context: PtyExitDeliveryContext): boolean { const state = useAppStore.getState() - if (state.isPtyShutdownPending(ptyId) || state.suppressedPtyExitIds[ptyId]) { - return true + const disposition = consumeCommittedPtyShutdownExit(ptyId, null) + if (disposition && context.exitOptions) { + if (disposition === true || context.exitOptions.preserveRendererBinding !== true) { + context.exitOptions.preserveRendererBinding = disposition + } } - return consumeCommittedPtyShutdownExit(ptyId, null) + return Boolean( + context.exitOptions?.preserveRendererBinding || + disposition || + state.isPtyShutdownPending(ptyId) || + state.suppressedPtyExitIds[ptyId] + ) } export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 4344d28495f9..fd5dd85e13a0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -170,3 +170,52 @@ export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet } } } + +/** Permanent removal consumes one leaf's sidecars, including ended watcher slots. */ +export function retireParkedTerminalLeaf( + original: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, + retainedPtyIds: ReadonlySet = new Set() +): number[] { + const { worktreeId, tabId, leafId, ptyId } = original + const capture = capturedPanesByTabId.get(tabId) + const entry = parkedWatchersByTabId.get(tabId) + if ( + (capture && capture.worktreeId !== worktreeId) || + (entry && entry.worktreeId !== worktreeId) + ) { + throw new Error('terminal_pane_owner_changed') + } + const panes = capture?.panes.filter((pane) => pane.leafId === leafId) ?? [] + if (panes.some((pane) => pane.ptyId !== null && pane.ptyId !== ptyId)) { + throw new Error('terminal_pane_owner_changed') + } + const ids = new Set(panes.flatMap((pane) => (pane.ptyId ? [pane.ptyId] : []))) + if (ptyId) { + ids.add(ptyId) + } + const paneIds = new Set(panes.map((pane) => pane.paneId)) + for (const id of ids) { + if (retainedPtyIds.has(id)) { + continue + } + const paneId = entry?.paneIdByPtyId.get(id) + if (paneId !== undefined) { + paneIds.add(paneId) + } + entry?.disposersByPtyId.get(id)?.() + entry?.disposersByPtyId.delete(id) + entry?.paneIdByPtyId.delete(id) + discardPreHandlerPtyState(id) + } + if (entry && entry.disposersByPtyId.size === 0 && entry.paneIdByPtyId.size === 0) { + parkedWatchersByTabId.delete(tabId) + } + if (capture) { + capture.panes = capture.panes.filter((pane) => pane.leafId !== leafId) + if (capture.panes.length === 0) { + capturedPanesByTabId.delete(tabId) + } + } + releaseTerminalScrollIntentKey(leafId) + return [...paneIds] +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts index 2175177f7a62..23f565869ade 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts @@ -18,7 +18,10 @@ vi.mock('./parked-terminal-byte-watcher', () => ({ } })) -type ExitCallback = (code: number, context: { hadPrimary: boolean }) => void +type ExitCallback = ( + code: number, + context: { hadPrimary: boolean; exitOptions?: PtyExitOptions } +) => void const exitCallbacksByPtyId = new Map() vi.mock('./pty-dispatcher', () => ({ subscribeToPtyExit: (ptyId: string, callback: ExitCallback) => { @@ -61,6 +64,7 @@ import { } from './pty-shutdown-exit-deferral' import { startParkedPtyWatcher } from './terminal-parked-pty-watcher' import { deliverPtyExitToHandlers } from './pty-exit-delivery' +import type { PtyExitOptions } from './pty-exit-delivery' import type { ParkedTabWatcherEntry } from './terminal-parked-watcher-registry' function startSplitWatchers(): ParkedTabWatcherEntry { @@ -113,12 +117,24 @@ describe('sleep-preserved parked exits (sole-owner sidecar)', () => { it.each([ ['pending renderer shutdown', () => mockStoreState.isPtyShutdownPending.mockReturnValue(true)], ['suppressed intentional restart', () => (mockStoreState.suppressedPtyExitIds[PTY_ID] = true)], - ['committed sleep marker', () => markCommittedPtyShutdowns([PTY_ID])] + ['committed sleep marker', () => markCommittedPtyShutdowns([PTY_ID])], + [ + 'closing marker with generic suppression', + () => { + markCommittedPtyShutdowns([PTY_ID], 'closing') + mockStoreState.suppressedPtyExitIds[PTY_ID] = true + } + ] ] as const)('keeps the tab and layout on a %s exit', (_marker, arm) => { const entry = startSplitWatchers() arm() - exitCallbacksByPtyId.get(PTY_ID)?.(0, { hadPrimary: false }) + const exitOptions: PtyExitOptions = + _marker === 'committed sleep marker' ? { preserveRendererBinding: 'closing' } : {} + exitCallbacksByPtyId.get(PTY_ID)?.(0, { hadPrimary: false, exitOptions }) + if (_marker === 'committed sleep marker') { + expect(exitOptions.preserveRendererBinding).toBe(true) + } expect(closeTerminalTab).not.toHaveBeenCalled() expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() @@ -151,16 +167,19 @@ describe('sleep-preserved parked exits (sole-owner sidecar)', () => { expect(entry.disposersByPtyId.size).toBe(0) }) - it('leaves the committed marker to the primary when one handled the exit', () => { - startSplitWatchers() - markCommittedPtyShutdowns([PTY_ID]) + it.each([true, 'closing'] as const)( + 'leaves the committed marker %s to the primary when one handled the exit', + (disposition) => { + startSplitWatchers() + markCommittedPtyShutdowns([PTY_ID], disposition) - exitCallbacksByPtyId.get(PTY_ID)?.(0, { hadPrimary: true }) + exitCallbacksByPtyId.get(PTY_ID)?.(0, { hadPrimary: true }) - // hadPrimary skips the guard entirely: marker untouched, watcher disposed. - expect(consumeCommittedPtyShutdownExit(PTY_ID, null)).toBe(true) - expect(startedWatcherDisposers[0]).toHaveBeenCalled() - }) + // hadPrimary skips the guard entirely: marker untouched, watcher disposed. + expect(consumeCommittedPtyShutdownExit(PTY_ID, null)).toBe(disposition) + expect(startedWatcherDisposers[0]).toHaveBeenCalled() + } + ) it('still collapses the dead leaf on an ordinary parked split exit', () => { startSplitWatchers() diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts new file mode 100644 index 000000000000..a64213671c02 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts @@ -0,0 +1,260 @@ +import { vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { Terminal } from '@xterm/xterm' +import { SearchAddon } from '@xterm/addon-search' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { FitAddon } from '@xterm/addon-fit' +import { WebglAddon } from '@xterm/addon-webgl' +import { ImageAddon } from '@xterm/addon-image' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' +import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { retireManagedPanePreservingPty } from '@/lib/pane-manager/pane-split-close' +import { createTerminalPaneClosedHandler } from './terminal-pane-pane-closed' +import { registerTerminalPaneMount } from './terminal-pane-mount-registration' +import { cleanupTerminalPaneMount } from './terminal-pane-mount-cleanup' +import { installTerminalPaneMountEvents } from './terminal-pane-mount-events' +import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' +import { useTerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-refs' +import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types' +import type { PtyTransport } from './pty-transport-types' +import { useAppStore } from '@/store' + +export const LEAF = '11111111-1111-4111-8111-111111111111' + +function disposable(value: T): T { + return Object.assign(value, { dispose: vi.fn<() => void>() }) +} + +function pane(id: number, leafId: string): ManagedPaneInternal { + if (!isTerminalLeafId(leafId)) { + throw new Error('Expected a valid fixture leaf id') + } + const terminal = disposable(new Terminal()) + vi.spyOn(terminal, 'clearSelection').mockImplementation(() => {}) + vi.spyOn(terminal, 'focus').mockImplementation(() => {}) + const container = document.createElement('div') + container.classList.add('pane') + container.dataset.leafId = leafId + return { + id, + leafId, + stablePaneId: leafId, + terminal, + container, + xtermContainer: container, + linkTooltip: document.createElement('div'), + terminalGpuAcceleration: 'off', + gpuRenderingEnabled: false, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null, + searchAddon: disposable(new SearchAddon()), + serializeAddon: disposable(new SerializeAddon()), + unicode11Addon: disposable(new Unicode11Addon()), + webLinksAddon: disposable(new WebLinksAddon()), + fitAddon: disposable(new FitAddon()), + webglAddon: disposable(new WebglAddon()), + imageAddon: disposable(new ImageAddon()), + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + paneDragCleanup: vi.fn(), + focusClassSyncCleanup: vi.fn() + } +} + +type MountedOwner = { + source: ManagedPaneInternal + sourceTransport: PtyTransport + transports: Map + manager: PaneManager + unregister: () => void +} + +/** Uses the production tree teardown, real pane disposer and real mount closure callback. */ +export function mountedOwner( + address = { + tabId: 'terminal', + worktreeId: 'wt-1', + leafId: LEAF, + ptyId: 'pty-1' + } +): MountedOwner { + const source = pane(1, address.leafId) + const panes = new Map([[1, source]]) + const sourceTransport: PtyTransport = { + getPtyId: () => address.ptyId, + detach: vi.fn(), + destroy: vi.fn(), + connect: vi.fn(), + attach: vi.fn(), + disconnect: vi.fn(), + sendInput: () => true, + sendInputImmediate: () => true, + resize: () => true, + isConnected: () => false + } + const transports = new Map([[1, sourceTransport]]) + const binding = { dispose: vi.fn() } + const bindings = new Map([[1, binding]]) + const lifecycleRefs = renderHook(useTerminalPaneLifecycleRefs) + const refs = lifecycleRefs.result.current + const ref = () => ({ current: new Map() }) + const link = { dispose: vi.fn() } + refs.linkProviderDisposablesRef.current.set(1, link) + const state = useAppStore.getState() + let manager: PaneManager + const managerRef: { current: PaneManager | null } = { current: null } + const deps: UseTerminalPaneLifecycleDeps = { + tabId: address.tabId, + worktreeId: address.worktreeId, + managerRef, + containerRef: { current: null }, + paneTransportsRef: { current: transports }, + panePtyBindingsRef: { current: bindings }, + paneMode2031Ref: ref(), + paneKittyKeyboardModesRef: ref(), + paneLastThemeModeRef: ref(), + paneCwdRef: ref(), + paneFontSizesRef: ref(), + replayingPanesRef: refs.replayingPanesRef, + clearRuntimePaneTitle: state.clearRuntimePaneTitle, + syncPanePtyLayoutBinding: vi.fn(), + clearTabPtyId: state.clearTabPtyId, + setPaneTitles: vi.fn(), + paneTitlesRef: { current: {} }, + setRenamingPaneId: vi.fn(), + setPaneCount: vi.fn(), + setTabPaneExpanded: state.setTabPaneExpanded, + setTabCanExpandPane: state.setTabCanExpandPane, + updateTabTitle: state.updateTabTitle, + persistLayoutSnapshot: vi.fn(), + getTabWideAgentHintLeafId: () => null, + isActive: false, + isVisible: false, + systemPrefersDark: false, + settings: null, + settingsRef: { current: null }, + requestOpenLinksInAppPreference: vi.fn(), + requestTerminalLinkAction: vi.fn(), + effectiveMacOptionAsAlt: 'false', + effectiveMacOptionAsAltRef: { current: 'false' }, + initialLayoutRef: { + current: { + root: { type: 'leaf', leafId: address.leafId }, + activeLeafId: address.leafId, + expandedLeafId: null + } + }, + expandedStyleSnapshotRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + onPtyExitRef: { current: vi.fn() }, + onAgentExitedRef: { current: vi.fn() }, + consumeSuppressedPtyExit: () => false, + isPtyShutdownPending: () => false, + setRuntimePaneTitle: state.setRuntimePaneTitle, + updateTabPtyId: state.updateTabPtyId, + markTerminalBell: state.markTerminalBell, + onShowSessionRestoredBanner: vi.fn(), + dispatchNotification: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + clearExitedPanePtyLayoutBinding: vi.fn(), + setExpandedPane: vi.fn(), + syncExpandedLayout: vi.fn(), + showPaneTitle: vi.fn(), + setPaneLayoutRevision: vi.fn() + } + const context: Pick< + PaneClosedHandlerContext, + 'deps' | 'refs' | 'deferredSplitHandoffs' | 'getOwnedManager' + > = { + deps, + refs, + deferredSplitHandoffs: new Map(), + getOwnedManager: () => manager + } + const onPaneClosed = createTerminalPaneClosedHandler(context) + const onLayoutChanged = vi.fn() + manager = new PaneManager(document.createElement('div'), { linkOpenHint: () => '' }) + vi.spyOn(manager, 'getPanes').mockImplementation(() => [...panes.values()]) + vi.spyOn(manager, 'getActivePane').mockImplementation(() => panes.get(1) ?? null) + vi.spyOn(manager, 'retirePanePreservingPty').mockImplementation((id, permanent, failures) => + retireManagedPanePreservingPty( + { + paneId: id, + failures, + activePaneId: 1, + panes, + root: document.createElement('div'), + styleOptions: {}, + managerOptions: { linkOpenHint: () => '', onPaneClosed, onLayoutChanged }, + getDragCallbacks: () => ({ + getPanes: () => panes, + getRoot: () => source.container, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn() + }), + releasePaneIdentity: vi.fn(), + setActivePaneId: vi.fn() + }, + permanent + ) + ) + vi.spyOn(manager, 'destroy') + managerRef.current = manager + const cancelResize = vi.fn() + const expandedElement = document.createElement('div') + expandedElement.style.display = 'none' + const expandedStyleSnapshots = new Map([[expandedElement, { display: 'flex', flex: '1' }]]) + const removeMountEvents = installTerminalPaneMountEvents({ + manager, + deps: { + ...deps, + isActive: false, + showPaneTitle: vi.fn(), + syncCanExpandState: vi.fn(), + queueResizeAll: vi.fn() + }, + ptyDeps: { ...deps, mountFollowsTerminalPark: false } + }) + const empty = vi.fn(() => { + removeMountEvents() + cleanupTerminalPaneMount({ + manager, + deps: context.deps, + refs: context.refs, + deferredSplitHandoffs: context.deferredSplitHandoffs, + expandedStyleSnapshots, + unregisterRuntimeTab: registration.unregisterRuntimeTab, + cancelResize, + permanent: true + }) + }) + const registration = registerTerminalPaneMount(context, empty) + registration.setOwnedManager(manager) + // A null rendering ref does not release the actual mounted owner. + managerRef.current = null + return { + source, + sourceTransport, + transports, + manager, + unregister: () => { + removeMountEvents() + registration.unregisterRuntimeTab() + manager.destroy() + lifecycleRefs.unmount() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts new file mode 100644 index 000000000000..8c024ae1aa9b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts @@ -0,0 +1,89 @@ +import { useAppStore } from '@/store' +import { findRegisteredTerminalTab } from '@/runtime/mounted-terminal-tab-registry' +import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard' +import { + captureTerminalSurfaceIdentity, + isRetainedTerminalSurfaceIdentity, + type TerminalSurfaceCloseIdentity +} from '../../../../shared/terminal-tab-close' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { retireParkedTerminalLeaf } from './terminal-parked-watcher-registry' +import { collapseParkedTerminalLeaf } from './terminal-parked-pty-watcher' + +/** Admission and disposal share one synchronous owner action. */ +export function removeTerminalPresentation( + original: TerminalSurfaceCloseIdentity, + dispose: (failures: unknown[]) => void +): void { + const state = useAppStore.getState() + const current = captureTerminalSurfaceIdentity( + state, + original.worktreeId, + original.tabId, + original.leafId + ) + if (!isRetainedTerminalSurfaceIdentity(original, current)) { + throw new Error('terminal_pane_owner_changed') + } + const layout = state.terminalLayoutsByTabId[original.tabId] + const split = detachTerminalLayoutLeaf(layout, original.leafId) + if ( + current?.hasLeaf && + !split && + (isUnifiedTabPinned(state, original.worktreeId, original.tabId) || + state.tabsByWorktree[original.worktreeId]?.some( + (tab) => tab.id === original.tabId && tab.isPinned + )) + ) { + throw new Error('terminal_tab_pinned') + } + const retainedPtyIds = new Set( + Object.entries(layout?.ptyIdsByLeafId ?? {}) + .filter(([leafId]) => leafId !== original.leafId) + .map(([, ptyId]) => ptyId) + ) + const paneIds = retireParkedTerminalLeaf(original, retainedPtyIds) + for (const paneId of paneIds) { + state.clearRuntimePaneTitle(original.tabId, paneId) + } + const failures: unknown[] = [] + try { + dispose(failures) + if (current?.hasLeaf) { + if (split) { + useAppStore.getState().setTabLayout(original.tabId, split.sourceLayout) + } else { + useAppStore.getState().closeTab(original.tabId, { + reason: 'user', + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true, + remoteCloseOwnedByHost: true + }) + } + } + } catch (error) { + if (failures.length === 0) { + throw error + } + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'terminal_tab_close_failed') + } +} + +export function retireTerminalPresentation(original: TerminalSurfaceCloseIdentity): void { + const mounted = findRegisteredTerminalTab(original.tabId, original.worktreeId)?.tab + if (mounted) { + if (!mounted.retirePresentation) { + throw new Error('runtime_unavailable') + } + mounted.retirePresentation(original) + return + } + removeTerminalPresentation(original, () => { + const state = useAppStore.getState() + state.retireAgentPaneAuthority(`${original.tabId}:${original.leafId}`) + collapseParkedTerminalLeaf(original.tabId, original.leafId, original.ptyId ?? undefined) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index 3e31c5aefb7a..6bec879ea930 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -62,7 +62,10 @@ export function createTerminalTabAgentTypeSelector( } } for (const [paneKey, entry] of Object.entries(foreground)) { - if (!entry.agent || entry.shellForeground || entry.routingRevoked) { + const agent = + entry.suspendedAgent ?? + (!entry.shellForeground && !entry.routingRevoked ? entry.agent : null) + if (!agent) { continue } const separator = paneKey.indexOf(':') @@ -73,9 +76,9 @@ export function createTerminalTabAgentTypeSelector( const leafId = paneKey.slice(separator + 1) const byLeaf = nextByTabId.get(entryTabId) if (byLeaf) { - byLeaf[leafId] ??= entry.agent + byLeaf[leafId] ??= agent } else { - nextByTabId.set(entryTabId, { [leafId]: entry.agent }) + nextByTabId.set(entryTabId, { [leafId]: agent }) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 3692d9f3db8c..d08fdd2a3ecd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' @@ -216,6 +217,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController if (!unifiedTabId) { return } + noteViewerNavigation() if (effectiveChatViewMode && chatLeafId === leafId) { setChatLeafId(null) toggleTabViewMode(unifiedTabId) @@ -238,6 +240,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController const switchNativeChatToTerminal = useCallback(() => { if (chatLeafId && unifiedTabId) { setChatLeafId(null) + noteViewerNavigation() setTabViewMode(unifiedTabId, 'terminal') } }, [chatLeafId, setChatLeafId, setTabViewMode, unifiedTabId]) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts index a4e0e8d96442..deb446e8a8b2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -4,7 +4,10 @@ import { configureTerminalOutputBacklogCap } from '@/lib/pane-manager/pane-termi import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { e2eConfig } from '@/lib/e2e-config' import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' -import { prepareTerminalPaneMount } from './terminal-pane-mount-preparation' +import { + prepareTerminalPaneMount, + type TerminalPaneMountPreparation +} from './terminal-pane-mount-preparation' import { createTerminalPaneManagerOptions } from './terminal-pane-manager-options' import { restoreTerminalPaneLayout } from './terminal-pane-layout-restore' import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap' @@ -23,7 +26,31 @@ export function useTerminalPaneMountLifecycle( configureTerminalOutputBacklogCap(deps.settings?.terminalScrollbackRows) useEffect(() => { - const preparation = prepareTerminalPaneMount(deps, refs, mountFollowsTerminalPark) + let removeMountEvents = (): void => {} + const releaseWebviewDragPassthrough: { current: (() => void) | null } = { current: null } + const preparation: TerminalPaneMountPreparation | null = prepareTerminalPaneMount( + deps, + refs, + mountFollowsTerminalPark, + (manager) => { + if (!preparation) { + throw new Error('terminal_pane_owner_unavailable') + } + removeMountEvents() + cleanupTerminalPaneMount({ + manager, + deps, + refs, + deferredSplitHandoffs: preparation.deferredSplitHandoffs, + expandedStyleSnapshots: preparation.expandedStyleSnapshots, + unregisterRuntimeTab: preparation.unregisterRuntimeTab, + cancelResize: preparation.cancelResizeAll, + permanent: true + }) + releaseWebviewDragPassthrough.current?.() + releaseWebviewDragPassthrough.current = null + } + ) if (!preparation) { return } @@ -35,11 +62,9 @@ export function useTerminalPaneMountLifecycle( unregisterRuntimeTab } = preparation let shouldPersistLayout = false - const releaseWebviewDragPassthrough: { current: (() => void) | null } = { - current: null - } const queueResizeAll = preparation.queueResizeAll const managerContext = { + getOwnedManager: preparation.getOwnedManager, deps, refs, ptyDeps, @@ -72,6 +97,7 @@ export function useTerminalPaneMountLifecycle( } const manager = new PaneManager(container, createTerminalPaneManagerOptions(managerContext)) deps.managerRef.current = manager + preparation.setOwnedManager(manager) if (e2eConfig.exposeStore) { window.__paneManagers = window.__paneManagers ?? new Map() window.__paneManagers.set(deps.tabId, manager) @@ -107,7 +133,7 @@ export function useTerminalPaneMountLifecycle( queueResizeAll(deps.isActive) deps.persistLayoutSnapshot() scheduleRuntimeGraphSync() - const removeMountEvents = installTerminalPaneMountEvents({ + removeMountEvents = installTerminalPaneMountEvents({ manager, deps: { tabId: deps.tabId, @@ -123,15 +149,17 @@ export function useTerminalPaneMountLifecycle( }) return () => { removeMountEvents() - cleanupTerminalPaneMount({ - manager, - deps, - refs, - deferredSplitHandoffs, - expandedStyleSnapshots, - unregisterRuntimeTab, - cancelResize: preparation.cancelResizeAll - }) + if (preparation.getOwnedManager() === manager) { + cleanupTerminalPaneMount({ + manager, + deps, + refs, + deferredSplitHandoffs, + expandedStyleSnapshots, + unregisterRuntimeTab, + cancelResize: preparation.cancelResizeAll + }) + } releaseWebviewDragPassthrough.current?.() releaseWebviewDragPassthrough.current = null } diff --git a/src/renderer/src/components/ui/hover-card.tsx b/src/renderer/src/components/ui/hover-card.tsx index 41e50aaa2ab9..374e478e959a 100644 --- a/src/renderer/src/components/ui/hover-card.tsx +++ b/src/renderer/src/components/ui/hover-card.tsx @@ -19,7 +19,8 @@ const hoverCardContentVariants = cva('', { variant: { default: 'w-64 p-4', 'workspace-details': - 'w-[360px] max-w-[calc(100vw-2rem)] max-h-[28rem] overflow-y-auto p-3 text-xs scrollbar-sleek' + 'w-[360px] max-w-[calc(100vw-2rem)] max-h-[28rem] overflow-y-auto p-3 text-xs scrollbar-sleek', + 'tab-preview': 'w-64 max-w-[calc(100vw-1rem)] p-3 text-xs' } }, defaultVariants: { variant: 'default' } diff --git a/src/renderer/src/components/use-terminal-activation-actions.ts b/src/renderer/src/components/use-terminal-activation-actions.ts index cfa5190a1057..0d4936b536b3 100644 --- a/src/renderer/src/components/use-terminal-activation-actions.ts +++ b/src/renderer/src/components/use-terminal-activation-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import { useAppStore } from '../store' import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' @@ -11,6 +12,7 @@ export function useTerminalActivationActions(controller: TerminalBulkCloseContro const { activeWorktreeId, setActiveBrowserTab, setActiveTab, setActiveTabType } = controller const handleActivateTab = useCallback( (tabId: string) => { + noteViewerNavigation() const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ @@ -27,6 +29,7 @@ export function useTerminalActivationActions(controller: TerminalBulkCloseContro const handleTogglePaneExpand = useCallback( (tabId: string) => { + noteViewerNavigation() setActiveTab(tabId) requestAnimationFrame(() => { window.dispatchEvent( @@ -39,6 +42,7 @@ export function useTerminalActivationActions(controller: TerminalBulkCloseContro const handleActivateBrowserTab = useCallback( (tabId: string) => { + noteViewerNavigation() const state = useAppStore.getState() const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if ( diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 7fc7a9c470d3..8d1cf4e57c4e 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useCallback } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' @@ -30,6 +31,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr } = controller const handleNewTab = useCallback( (shellOverride?: string) => { + noteViewerNavigation() if (!activeWorktreeId) { return } @@ -64,6 +66,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr ) const handleNewSimulatorTab = useCallback(() => { + noteViewerNavigation() if (!activeWorktreeId) { return } @@ -78,6 +81,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr const openNewBrowserTab = useCallback( (splitDirection?: TabSplitDirection) => { + noteViewerNavigation() if (!activeWorktreeId) { return } diff --git a/src/renderer/src/hooks/automation-dispatch-completion.ts b/src/renderer/src/hooks/automation-dispatch-completion.ts index c0c2b5dc1d04..81dc62f52931 100644 --- a/src/renderer/src/hooks/automation-dispatch-completion.ts +++ b/src/renderer/src/hooks/automation-dispatch-completion.ts @@ -16,6 +16,7 @@ import { import type { Worktree } from '../../../shared/worktree/types' import { isProvenProcessExit } from '../../../shared/terminal-exit-cause' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import type { PtyExitOptions } from '@/components/terminal-pane/pty-exit-delivery' type MarkDispatchResult = (result: AutomationDispatchResult) => Promise @@ -32,13 +33,21 @@ export function createAutomationDispatchCompletion(args: { const getOutputSnapshot = () => selectAutomationRunOutputSnapshot(latestAssistantMessage, outputSnapshotBuffer.snapshot()) let dispatchMarked = false - let pendingExitCode: number | null = null + let pendingExit: { code: number; options?: PtyExitOptions } | null = null let pendingDone = false let completionMarked = false let contactLost = false let unsubscribeAgentStatus = (): void => {} let unsubscribeSessionObserver = (): void => {} let releaseReuseDispatchTab = (): void => {} + const releaseRetainedTerminalOwnership = (options?: PtyExitOptions): void => { + if ( + options?.preserveRendererBinding === true || + options?.preserveRendererBinding === 'closing' + ) { + args.releaseTerminalOwnership() + } + } const cleanupRunObservers = (): void => { unsubscribeAgentStatus() unsubscribeSessionObserver() @@ -147,12 +156,13 @@ export function createAutomationDispatchCompletion(args: { } settleLateResult(markCompletionResult()) } - const handleExit = (code: number): void => { + const handleExit = (code: number, options?: PtyExitOptions): void => { + releaseRetainedTerminalOwnership(options) if (completionMarked) { return } if (!dispatchMarked) { - pendingExitCode = code + pendingExit = { code, options } return } settleLateResult(markExitResult(code)) @@ -235,10 +245,12 @@ export function createAutomationDispatchCompletion(args: { }, settlePendingAfterDispatch: async () => { dispatchMarked = true + // An early exit can precede publication of the launch's retirement ownership. + releaseRetainedTerminalOwnership(pendingExit?.options) if (pendingDone) { await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) + } else if (pendingExit !== null) { + await markExitResult(pendingExit.code) } } } diff --git a/src/renderer/src/hooks/automation-dispatch-handler.ts b/src/renderer/src/hooks/automation-dispatch-handler.ts index 6f101bad103f..b6f3ba112fd6 100644 --- a/src/renderer/src/hooks/automation-dispatch-handler.ts +++ b/src/renderer/src/hooks/automation-dispatch-handler.ts @@ -179,8 +179,8 @@ export async function handleAutomationDispatchRequest({ } completion.handleAgentDone() }, - onExit: (_ptyId, code) => { - completion.handleExit(code) + onExit: (_ptyId, code, options) => { + completion.handleExit(code, options) } }) if (!result) { diff --git a/src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts b/src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts index a8a525495b92..69d2b058f629 100644 --- a/src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts +++ b/src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AutomationDispatchResult } from '../../../shared/automations-types' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import type { createTestStore } from '@/store/slices/store-test-helpers' + +let bindingStore: ReturnType | null = null vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ agentStatusByPaneKey: {} }), + getState: () => bindingStore?.getState() ?? { agentStatusByPaneKey: {} }, subscribe: vi.fn(() => () => {}) } })) @@ -35,6 +38,7 @@ async function createCompletion() { */ describe('automation dispatch completion on an unverifiable loss', () => { beforeEach(() => { + bindingStore = null vi.clearAllMocks() markDispatchResult.mockResolvedValue(undefined) finalizeTerminalOwnership.mockResolvedValue(null) @@ -99,4 +103,138 @@ describe('automation dispatch completion on an unverifiable loss', () => { ) warnSpy.mockRestore() }) + + it.each([ + 'pending exit', + 'pending done', + 'exit persistence', + 'done persistence', + 'ordinary completion' + ] as const)('conserves real unselected terminal ownership through %s', async (phase) => { + const { createTestStore, seedStore, makeWorktree, TEST_REPO } = + await import('@/store/slices/store-test-helpers') + const { makeRun } = await import('@/components/automations/automations-page-fixtures') + const { createAutomationTerminalOwnership } = + await import('@/lib/automation-terminal-ownership') + const { createAutomationDispatchCompletion } = await import('./automation-dispatch-completion') + vi.stubGlobal('window', { api: { pty: { kill: vi.fn(async () => {}) } } }) + try { + const store = createTestStore() + bindingStore = store + const worktree = makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'local' }) + seedStore(store, { worktreesByRepo: { [TEST_REPO.id]: [worktree] } }) + const selected = store.getState().createTab(worktree.id, undefined, undefined, { + initialPtyId: 'pty-C' + }) + const tab = store.getState().createTab(worktree.id, undefined, undefined, { + initialPtyId: 'pty-B', + activate: false, + recordInteraction: false + }) + store.getState().updateTabPtyId(tab.id, 'pty-B') + store.setState({ + activeWorktreeId: worktree.id, + activeTabId: selected.id, + activeTabType: 'terminal' + }) + const layout = store.getState().terminalLayoutsByTabId[tab.id] + const leafId = layout.activeLeafId + if (!leafId) { + throw new Error('Background terminal has no active leaf') + } + const paneKey = `${tab.id}:${leafId}` + const ownership = createAutomationTerminalOwnership({ + store, + worktreeId: worktree.id, + tabId: tab.id, + paneKey, + ptyId: 'pty-B', + tabCreatedAt: tab.createdAt, + runtimeKind: 'desktop' + }) + const pendingDispatch = phase === 'pending exit' || phase === 'pending done' + let acquiredOwnership: AutomationTerminalOwnership | null = pendingDispatch ? null : ownership + const release = vi.fn(() => acquiredOwnership?.release()) + const finalize = vi.fn((options) => + ownership.finalize(options) + ) + const close = vi.spyOn(store.getState(), 'closeTab') + const hibernate = vi.spyOn(store.getState(), 'shutdownCompletedAgentPaneForHibernation') + let finishResult!: () => void + const persistence = new Promise((resolve) => { + finishResult = resolve + }) + markDispatchResult.mockReturnValueOnce(persistence) + const completion = createAutomationDispatchCompletion({ + run: makeRun({ status: 'dispatched', workspaceId: worktree.id }), + worktree, + precheckResult: null, + markDispatchResult, + releaseTerminalOwnership: release, + finalizeTerminalOwnership: finalize + }) + const exitOptions = { preserveRendererBinding: 'closing' as const } + let settlement: Promise | undefined + if (pendingDispatch) { + if (phase === 'pending done') { + completion.handleAgentDone() + } + completion.handleExit(0, exitOptions) + acquiredOwnership = ownership + settlement = completion.settlePendingAfterDispatch() + } else { + await completion.settlePendingAfterDispatch() + if (phase === 'done persistence') { + completion.handleAgentDone() + } else { + completion.handleExit(0) + } + if (phase !== 'ordinary completion') { + completion.handleExit(0, exitOptions) + } + } + expect(markDispatchResult).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ runId: 'run-1', status: 'completed', error: null }) + ) + expect(close).not.toHaveBeenCalled() + expect(hibernate).not.toHaveBeenCalled() + expect(store.getState().activeTabId).toBe(selected.id) + expect(store.getState().lastTerminalInputAtByPaneKey[paneKey]).toBeUndefined() + finishResult() + await settlement + await vi.waitFor(() => + expect(finalize).toHaveBeenCalledExactlyOnceWith({ preservePane: false }) + ) + if (phase === 'ordinary completion') { + await vi.waitFor(() => expect(markDispatchResult).toHaveBeenCalledTimes(2)) + expect(close).toHaveBeenCalledExactlyOnceWith(tab.id, { + recordInteraction: false, + reason: 'cleanup' + }) + expect(store.getState().tabsByWorktree[worktree.id].some((t) => t.id === tab.id)).toBe( + false + ) + expect(store.getState().ptyIdsByTabId[tab.id]).toBeUndefined() + expect(release).not.toHaveBeenCalled() + } else { + expect(markDispatchResult).toHaveBeenCalledOnce() + expect(close).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[worktree.id].find((t) => t.id === tab.id)).toBe(tab) + expect(store.getState().ptyIdsByTabId[tab.id]).toEqual(['pty-B']) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toBe(layout) + expect(store.getState().lastKnownRelayPtyIdByTabId[tab.id]).toBe('pty-B') + expect(release).toHaveBeenCalled() + } + expect(hibernate).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[worktree.id].find((t) => t.id === selected.id)).toBe( + selected + ) + expect(store.getState().activeTabId).toBe(selected.id) + expect(store.getState().lastTerminalInputAtByPaneKey[paneKey]).toBeUndefined() + ownership.release() + } finally { + bindingStore = null + vi.unstubAllGlobals() + } + }) }) diff --git a/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts index 528dcb6af24c..3f7c9345acb2 100644 --- a/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts @@ -4,12 +4,15 @@ import { applyAgentLaunchPaneVerdict } from '@/lib/agent-launch-pane-verdict-app import { keepAgentLaunchTabMainWrote } from '@/lib/agent-launch-tab-main-written' import { afterLayoutFrame } from '@/runtime/layout-frame-position' -function replyToPublish(request: AgentLaunchTabPublishRequest): void { +function replyToPublish( + request: AgentLaunchTabPublishRequest, + kept?: ReturnType +): void { try { window.api.ui.replyAgentLaunchTabPublish({ requestId: request.requestId, ...(request.shownByMain - ? keepAgentLaunchTabMainWrote(request) + ? (kept ?? keepAgentLaunchTabMainWrote(request)) : publishAgentLaunchTab(request)) }) } catch (error) { @@ -22,12 +25,15 @@ function replyToPublish(request: AgentLaunchTabPublishRequest): void { export function registerAgentLaunchTabIpcBridge(unsubs: (() => void)[]): void { unsubs.push( - window.api.ui.onPublishAgentLaunchTab((request) => - // A tab main wrote is kept once this window holds the frame that draws it. - request.shownByMain - ? afterLayoutFrame(request.shownByMain.frameSeq, () => replyToPublish(request)) - : replyToPublish(request) - ), + window.api.ui.onPublishAgentLaunchTab((request) => { + // Retain the exact publication before a verdict can overtake its frame. + if (request.shownByMain) { + const kept = keepAgentLaunchTabMainWrote(request) + afterLayoutFrame(request.shownByMain.frameSeq, () => replyToPublish(request, kept)) + } else { + replyToPublish(request) + } + }), window.api.ui.onAgentLaunchPaneVerdict(applyAgentLaunchPaneVerdict) ) } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index 398b40d79916..a2fd72669f18 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -1,3 +1,4 @@ +import { retireTerminalPresentation } from '@/components/terminal-pane/terminal-presentation-retirement' import { createRuntimeFilePositionQueue } from './runtime-file-position' import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' @@ -167,7 +168,7 @@ export function registerMobileAndTerminalCloseIpcBridge( if (window.api.ui.onTerminalTabCloseRequest) { unsubs.push( window.api.ui.onTerminalTabCloseRequest( - ({ requestId, tabId, localPtyTeardownOwnedExternally, force }) => { + ({ requestId, tabId, localPtyTeardownOwnedExternally, force, originalSurface }) => { let responded = false const respond = (error?: string): void => { if (responded) { @@ -176,6 +177,39 @@ export function registerMobileAndTerminalCloseIpcBridge( responded = true window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) } + if (originalSurface) { + void (async () => { + if (originalSurface.tabId !== tabId || !localPtyTeardownOwnedExternally) { + throw new Error('terminal_pane_owner_changed') + } + const failures: unknown[] = [] + try { + retireTerminalPresentation(originalSurface) + } catch (error) { + failures.push(error) + } + try { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + } catch (error) { + failures.push(error) + } + if (failures.length === 1) { + throw failures[0] + } + if (failures.length > 1) { + throw new AggregateError(failures, 'terminal_tab_close_failed') + } + respond() + })().catch((error: unknown) => + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + ) + return + } closeTerminalTab(tabId, { rejectPinned: true, ...(force ? { force: true } : {}), diff --git a/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts index fcbf23ca82c1..d5851fd49086 100644 --- a/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts @@ -1,3 +1,9 @@ +import { disposeViewerSelection } from '@/lib/viewer-selection-request' +import { + disposeSwitchProjections, + captureSwitchProjection, + retireSwitchProjection +} from '@/lib/conversation-switch-projection' import { isLocalSessionTabCloseOwned } from '@/runtime/local-session-tab-close-owner' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { @@ -22,6 +28,7 @@ import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' import { resolveWindowTabIdForHostTab } from './host-session-tab-target' export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void { + unsubs.push(disposeSwitchProjections, disposeViewerSelection) unsubs.push( window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { if (isLocalSessionTabCloseOwned(worktreeId, tabId)) { @@ -50,69 +57,89 @@ export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void { ) unsubs.push( - window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { - const localTabId = resolveWindowTabIdForHostTab(worktreeId, tabId) - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, localTabId) - let cancelConfirmation: (() => void) | undefined - let timeout: ReturnType | undefined - let settled = false - const respond = (error?: string): void => { - if (settled) { + window.api.ui.onSessionTabCloseRequest( + ({ requestId, tabId, worktreeId, expiresAt, switchProjection }) => { + if (switchProjection) { + let switchApplied = false + useAppStore.setState((state) => { + if (expiresAt !== undefined && Date.now() >= expiresAt) { + return state + } + if (switchProjection.phase === 'prepare') { + captureSwitchProjection(state, worktreeId, switchProjection, () => {}) + switchApplied = true + return state + } + const patch = retireSwitchProjection(state, switchProjection) + switchApplied = patch !== null + return patch ?? state + }) + window.api.ui.respondSessionTabClose({ requestId, switchApplied }) return } - settled = true - if (timeout !== undefined) { - clearTimeout(timeout) + const localTabId = resolveWindowTabIdForHostTab(worktreeId, tabId) + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, localTabId) + let cancelConfirmation: (() => void) | undefined + let timeout: ReturnType | undefined + let settled = false + const respond = (error?: string): void => { + if (settled) { + return + } + settled = true + if (timeout !== undefined) { + clearTimeout(timeout) + } + window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) } - window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) - } - if (expiresAt !== undefined) { - timeout = setTimeout( - () => { - cancelConfirmation?.() - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - }, - Math.max(0, expiresAt - Date.now()) - ) - } - const closeAndRespond = (): void => { - if (expiresAt !== undefined && Date.now() >= expiresAt) { - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - return + if (expiresAt !== undefined) { + timeout = setTimeout( + () => { + cancelConfirmation?.() + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + }, + Math.max(0, expiresAt - Date.now()) + ) } - try { - if (browserTarget) { - useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - respond() + const closeAndRespond = (): void => { + if (expiresAt !== undefined && Date.now() >= expiresAt) { + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) return } - const closed = closeMobileSessionTabInStore( - useAppStore.getState(), - worktreeId, - localTabId + try { + if (browserTarget) { + useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + respond() + return + } + const closed = closeMobileSessionTabInStore( + useAppStore.getState(), + worktreeId, + localTabId + ) + respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) + } catch (error) { + respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) + } + } + if (isLocalSessionTabCloseOwned(worktreeId, tabId)) { + respond( + expiresAt !== undefined && Date.now() >= expiresAt + ? SESSION_TAB_CLOSE_TIMEOUT_ERROR + : undefined ) - respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) - } catch (error) { - respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) + return } + const visibleId = browserTarget?.workspaceId ?? localTabId + cancelConfirmation = guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), + onClose: closeAndRespond, + onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) + }) } - if (isLocalSessionTabCloseOwned(worktreeId, tabId)) { - respond( - expiresAt !== undefined && Date.now() >= expiresAt - ? SESSION_TAB_CLOSE_TIMEOUT_ERROR - : undefined - ) - return - } - const visibleId = browserTarget?.workspaceId ?? localTabId - cancelConfirmation = guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), - onClose: closeAndRespond, - onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) - }) - }) + ) ) unsubs.push( diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts index 4649c299bd85..8907b8a0b0b0 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTabsSliceMockApi } from '../../store/slices/tabs-slice-test-harness' @@ -40,6 +41,8 @@ type CreateRequest = { requestId: string worktreeId: string targetGroupId?: string + afterTabId?: string + source?: 'runtime-session' presentation?: 'focused' | 'background' } @@ -53,6 +56,7 @@ beforeEach(() => { replyTerminalCreate.mockClear() vi.stubGlobal('window', { ...globalThis.window, + dispatchEvent: vi.fn(() => true), api: { ...globalThis.window?.api, ui: { diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts index 64898b7ef134..eb5f52a9e8d9 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -71,6 +71,7 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { ...(data.resumeProviderSession ? { resumeProviderSession: data.resumeProviderSession } : {}), + ...(data.resumeOrigin ? { resumeOrigin: data.resumeOrigin } : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), ...(data.startupCommandDelivery diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts index 7538f0fc793b..579fac02b9d6 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -1,3 +1,7 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' +import { isSessionTabSelectionRequest } from '../../../../shared/session-tab-placement' +import { selectSessionTabForViewer } from '@/runtime/web-session-tabs-sync/store-patch' +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' import type { SplitTerminalPaneDetail } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { @@ -131,10 +135,15 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void flashFocusedPane, scrollToBottomIfOutputSinceLastView }) => { + const notificationFocus = + ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView + if (notificationFocus) { + noteViewerNavigation() + } const store = useAppStore.getState() revealWorkspaceSurface(store, worktreeId) store.setActiveTab(tabId) - if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { + if (notificationFocus) { activateTabAndFocusPane(tabId, leafId ?? null, { ...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}), ...(flashFocusedPane ? { flashFocusedPane: true } : {}), @@ -150,7 +159,13 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void ) unsubs.push( - window.api.ui.onFocusEditorTab(({ tabId, worktreeId, userInitiated }) => { + window.api.ui.onFocusEditorTab(({ tabId, worktreeId, userInitiated, viewerSelection }) => { + if (userInitiated && viewerSelection === undefined) { + noteViewerNavigation() + } + if (!conversationSwitchViewerSelectionCurrent(viewerSelection)) { + return + } const localTabId = resolveWindowTabIdForHostTab(worktreeId, tabId) const store = useAppStore.getState() const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find( @@ -160,6 +175,7 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void // Courtesy completion reveals must not undo user navigation; explicit notification clicks may reveal before async activation updates the active ID. if ( !userInitiated && + viewerSelection === undefined && tab?.contentType === 'agent-session' && store.activeWorktreeId !== worktreeId ) { @@ -174,6 +190,10 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void } return } + if (viewerSelection && isSessionTabSelectionRequest(viewerSelection)) { + selectSessionTabForViewer(worktreeId, tab.id, viewerSelection) + return + } revealWorkspaceSurface(store, worktreeId) store.focusGroup(worktreeId, tab.groupId) store.activateTab(tab.id) diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index c2543fd24233..f070cbf9d630 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { useAppStore } from '../store' import { getActiveTabNavOrder } from '@/components/tab-bar/group-tab-order' import { @@ -44,6 +45,7 @@ function resolveCycleContext(): CycleContext | null { /** Activate by unified tab ID after file/browser updates so duplicate entities focus the correct split instance. */ export function activateCyclableTab(store: AppStoreState, next: TypeCyclableTab): void { + noteViewerNavigation() const worktreeId = store.activeWorktreeId if (next.type === 'terminal') { store.setActiveTab(next.id) @@ -309,6 +311,7 @@ export function handleSwitchTerminalTab(direction: number): boolean { if (next.id === store.activeTabId && store.activeTabType === 'terminal') { return false } + noteViewerNavigation() store.setActiveTab(next.id) store.setActiveTabType('terminal', worktreeId) activateCycledTabOnOwner(worktreeId, next) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts index 198d4e8ad073..b015f0be5111 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestStore, makeTab, @@ -620,3 +620,13 @@ describe('useIpcEvents agent status snapshot integration', () => { } }) }) + +const cleanup: (() => void)[] = [] +afterEach(() => { + for (const close of cleanup.splice(0).toReversed()) { + close() + } + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() +}) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 5c5798fcc8d9..02dee2f44c03 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1902,6 +1902,21 @@ "left": "Close tabs to the left?", "others": "Close other tabs?", "right": "Close tabs to the right?" + }, + "switch": { + "openElsewhere": "{{agent}} is open in another chat or terminal. Continue there, then try switching again.", + "stopUnknown": "Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try switching again.", + "outcomeUnknown": "Orca could not confirm the {{agent}} switch. Check the open chat and terminal before trying again.", + "historyUnavailable": "{{agent}} couldn't open this conversation's history. Check that its saved conversation is available, then try again.", + "chatStartFailed": "{{agent}} could not start in chat. Check its setup and try again.", + "terminalStartFailed": "{{agent}} could not start in a terminal. Check its setup and try again.", + "writerUnconfirmed": "Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try this action again.", + "sourceExitUnconfirmed": "Orca could not confirm {{agent}} stopped in this terminal. Quit {{agent}} here, then try this action again.", + "sourceKept": "{{agent}} opened successfully. The original tab was kept; continue in the new tab.", + "sourceStopUnknown": "Orca could not confirm {{agent}} stopped. Check {{agent}} in the original terminal, then try switching again." + }, + "SortableTabContextMenu": { + "switchToTerminalView": "Continue with Terminal Agent" } } }, diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 3c9c0335fa1b..eb5611b38ce5 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -19338,8 +19338,8 @@ }, "resumeInNewChat": { "hostUpdateRequired": "Orca auf diesem Host aktualisieren, um in der Chat-Oberfläche fortzufahren.", - "exitUnconfirmedFromTerminal": "{{agent}} führt diese Unterhaltung bereits in einem anderen Terminal aus. Dort {{agent}} beenden oder das Terminal schließen und anschließend diese Unterhaltung über den Sitzungsverlauf öffnen.", - "exitUnconfirmed": "{{agent}} führt diese Unterhaltung bereits in einem anderen Terminal aus. Dort {{agent}} beenden oder das Terminal schließen und anschließend diese Unterhaltung über den Sitzungsverlauf öffnen.", + "exitUnconfirmedFromTerminal": "Orca konnte nicht bestätigen, dass {{agent}} in diesem Terminal beendet wurde. Beende {{agent}} hier und versuche diese Aktion erneut.", + "exitUnconfirmed": "Orca konnte nicht bestätigen, dass {{agent}} beendet wurde. Beende {{agent}} in seinem Terminal und versuche diese Aktion erneut.", "notSavedYet": "{{agent}} hat diese Unterhaltung noch nicht gespeichert. Nach der Antwort erneut versuchen." }, "state": { @@ -19745,7 +19745,8 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Mit Terminal-Agenten fortfahren", - "switchToChatView": "In der Chat-Oberfläche fortfahren" + "switchToChatView": "In der Chat-Oberfläche fortfahren", + "showAsTerminal": "Als Terminal anzeigen" }, "BrowserTab": { "closeOthers": "Andere schließen", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b545f56f8ee3..4e58db1d19f5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19656,8 +19656,8 @@ }, "resumeInNewChat": { "hostUpdateRequired": "Update Orca on this host to continue in Chat UI.", - "exitUnconfirmedFromTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", - "exitUnconfirmed": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.", + "exitUnconfirmedFromTerminal": "Orca could not confirm {{agent}} stopped in this terminal. Quit {{agent}} here, then try this action again.", + "exitUnconfirmed": "Orca could not confirm {{agent}} stopped. Quit {{agent}} in its terminal, then try this action again.", "notSavedYet": "{{agent}} hasn't saved this conversation yet. Try again after it replies." }, "state": { @@ -20080,7 +20080,29 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Continue with Terminal Agent", - "switchToChatView": "Continue in Chat UI" + "switchToChatView": "Show as chat", + "showAsTerminal": "Show as terminal" + }, + "switch": { + "legacyCopy": "A copy of this {{agent}} conversation opens in a new terminal. The original chat stays open.", + "toCli": "Switch to CLI", + "toNativeChat": "Switch to native chat", + "toCliTooltip": "Switch to CLI: stops {{agent}} in native chat and continues the same conversation in a terminal.", + "openedInWorkspace": "Opened chat in {{workspace}}.", + "toNativeChatTooltip": "Switch to native chat: continues the same conversation in a chat, in this tab.", + "noConversation": "No saved conversation is available in this terminal yet.", + "openElsewhere": "{{agent}} is open in another chat or terminal. Continue there, then try switching again.", + "stopUnknown": "Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try switching again.", + "sourceStopUnknown": "Orca could not confirm {{agent}} stopped. Check {{agent}} in the original terminal, then try switching again.", + "stopAndSwitchTitle": "{{agent}} is replying. Stop it and switch?", + "stopAndSwitch": "Stop and switch", + "outcomeUnknown": "Orca could not confirm the {{agent}} switch. Check the open chat and terminal before trying again.", + "historyUnavailable": "{{agent}} couldn't open this conversation's history. Check that its saved conversation is available, then try again.", + "chatStartFailed": "{{agent}} could not start in chat. Check its setup and try again.", + "terminalStartFailed": "{{agent}} could not start in a terminal. Check its setup and try again.", + "writerUnconfirmed": "Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try this action again.", + "sourceExitUnconfirmed": "Orca could not confirm {{agent}} stopped in this terminal. Quit {{agent}} here, then try this action again.", + "sourceKept": "{{agent}} opened successfully. The original tab was kept; continue in the new tab." }, "BrowserTab": { "closeOthers": "Close Others", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a9f293c3c67d..e902855d037f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19188,8 +19188,8 @@ "resumeInNewChat": { "hostUpdateRequired": "Actualiza Orca en este host para continuar en la interfaz de chat.", "notSavedYet": "{{agent}} aún no ha guardado esta conversación. Inténtalo de nuevo cuando responda.", - "exitUnconfirmed": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones.", - "exitUnconfirmedFromTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal; luego abre esta conversación desde el Historial de sesiones." + "exitUnconfirmed": "Orca no pudo confirmar que {{agent}} se detuvo. Sal de {{agent}} en su terminal y vuelve a intentar esta acción.", + "exitUnconfirmedFromTerminal": "Orca no pudo confirmar que {{agent}} se detuvo en esta terminal. Sal de {{agent}} aquí y vuelve a intentar esta acción." }, "state": { "loading": { @@ -19715,7 +19715,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Continuar con el agente de terminal", - "switchToChatView": "Continuar en la interfaz de chat" + "switchToChatView": "Mostrar como chat", + "showAsTerminal": "Mostrar como terminal" + }, + "switch": { + "toCli": "Cambiar a la CLI", + "toNativeChat": "Cambiar al chat nativo", + "toCliTooltip": "Cambiar a la CLI: detiene {{agent}} en el chat nativo y continúa la misma conversación en una terminal.", + "openedInWorkspace": "Chat abierto en {{workspace}}.", + "toNativeChatTooltip": "Cambiar al chat nativo: continúa la misma conversación en un chat, en esta pestaña.", + "noConversation": "Todavía no hay una conversación guardada disponible en este terminal." }, "BrowserTab": { "closeOthers": "Cerrar otras", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f9812d4fc9bb..cd8320e31f81 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19408,8 +19408,8 @@ "resumeInNewChat": { "hostUpdateRequired": "Mettez à jour Orca sur cet hôte pour continuer dans l'interface de chat.", "notSavedYet": "{{agent}} n'a pas encore enregistré cette conversation. Réessayez après sa réponse.", - "exitUnconfirmedFromTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions.", - "exitUnconfirmed": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal, puis ouvrez cette conversation depuis l’historique des sessions." + "exitUnconfirmedFromTerminal": "Orca n’a pas pu confirmer l’arrêt de {{agent}} dans ce terminal. Quittez {{agent}} ici, puis réessayez cette action.", + "exitUnconfirmed": "Orca n’a pas pu confirmer l’arrêt de {{agent}}. Quittez {{agent}} dans son terminal, puis réessayez cette action." }, "state": { "loading": { @@ -19935,7 +19935,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Continuer avec l’agent de terminal", - "switchToChatView": "Continuer dans l’interface de chat" + "switchToChatView": "Afficher en chat", + "showAsTerminal": "Afficher en terminal" + }, + "switch": { + "toCli": "Passer à la CLI", + "toNativeChat": "Passer au chat natif", + "toCliTooltip": "Passer à la CLI : arrête {{agent}} dans le chat natif et poursuit la même conversation dans un terminal.", + "openedInWorkspace": "Chat ouvert dans {{workspace}}.", + "toNativeChatTooltip": "Passer au chat natif : poursuit la même conversation dans un chat, dans cet onglet.", + "noConversation": "Aucune conversation enregistrée n’est encore disponible dans ce terminal." }, "BrowserTab": { "closeOthers": "Fermer les autres", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5440d427b286..3ecc085cbc90 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18701,8 +18701,8 @@ "resumeInNewChat": { "hostUpdateRequired": "チャット UI で続けるには、このホストの Orca を更新してください。", "notSavedYet": "{{agent}} はまだこの会話を保存していません。応答してから再度お試しください。", - "exitUnconfirmedFromTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。", - "exitUnconfirmed": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じてから、セッション履歴でこの会話を開いてください。" + "exitUnconfirmedFromTerminal": "Orca は、このターミナルで {{agent}} が停止したことを確認できませんでした。ここで {{agent}} を終了してから、この操作をもう一度試してください。", + "exitUnconfirmed": "Orca は {{agent}} が停止したことを確認できませんでした。{{agent}} を実行中のターミナルで終了してから、この操作をもう一度試してください。" }, "state": { "loading": { @@ -19183,7 +19183,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "ターミナル Agent で続ける", - "switchToChatView": "チャット UI で続ける" + "switchToChatView": "チャットとして表示", + "showAsTerminal": "ターミナルとして表示" + }, + "switch": { + "toCli": "CLI に切り替える", + "toNativeChat": "ネイティブチャットに切り替える", + "toCliTooltip": "CLI に切り替え: ネイティブチャットで {{agent}} を停止し、ターミナルで同じ会話を続けます。", + "openedInWorkspace": "{{workspace}} でチャットを開きました。", + "toNativeChatTooltip": "ネイティブチャットに切り替え: このタブのチャットで同じ会話を続けます。", + "noConversation": "このターミナルには、保存された会話がまだありません。" }, "BrowserTab": { "closeOthers": "その他を閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9b338fe3b4d6..1fc5e618150c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18701,8 +18701,8 @@ "resumeInNewChat": { "hostUpdateRequired": "채팅 UI에서 계속하려면 이 호스트의 Orca를 업데이트하세요.", "notSavedYet": "{{agent}}이(가) 아직 이 대화를 저장하지 않았습니다. 응답한 후 다시 시도하세요.", - "exitUnconfirmedFromTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요.", - "exitUnconfirmed": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 다음 세션 기록에서 이 대화를 여세요." + "exitUnconfirmedFromTerminal": "Orca가 이 터미널에서 {{agent}}이(가) 중지되었는지 확인할 수 없습니다. 여기서 {{agent}}을(를) 종료한 다음 이 작업을 다시 시도하세요.", + "exitUnconfirmed": "Orca가 {{agent}}이(가) 중지되었는지 확인할 수 없습니다. 해당 터미널에서 {{agent}}을(를) 종료한 다음 이 작업을 다시 시도하세요." }, "state": { "loading": { @@ -19183,7 +19183,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "터미널 에이전트로 계속하기", - "switchToChatView": "채팅 UI에서 계속하기" + "switchToChatView": "채팅으로 표시", + "showAsTerminal": "터미널로 표시" + }, + "switch": { + "toCli": "CLI로 전환", + "toNativeChat": "네이티브 채팅으로 전환", + "toCliTooltip": "CLI로 전환: 네이티브 채팅에서 {{agent}}을(를) 중지하고 터미널에서 같은 대화를 이어갑니다.", + "openedInWorkspace": "{{workspace}}에서 채팅을 열었습니다.", + "toNativeChatTooltip": "네이티브 채팅으로 전환: 이 탭의 채팅에서 같은 대화를 이어갑니다.", + "noConversation": "이 터미널에는 아직 저장된 대화가 없습니다." }, "BrowserTab": { "closeOthers": "다른 탭 닫기", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index ef6883e97c1b..3b3b98a0ea6c 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20434,8 +20434,8 @@ "transcriptLabel": "Conversa", "resumeInNewChat": { "hostUpdateRequired": "Atualize o Orca neste host para continuar na interface de chat.", - "exitUnconfirmedFromTerminal": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", - "exitUnconfirmed": "{{agent}} já está executando esta conversa em outro terminal. Encerre o {{agent}} lá ou feche esse terminal e abra esta conversa no Histórico de sessões.", + "exitUnconfirmedFromTerminal": "O Orca não pôde confirmar que {{agent}} parou neste terminal. Encerre {{agent}} aqui e tente esta ação novamente.", + "exitUnconfirmed": "O Orca não pôde confirmar que {{agent}} parou. Encerre {{agent}} no terminal em que está sendo executado e tente esta ação novamente.", "notSavedYet": "O {{agent}} ainda não salvou esta conversa. Tente novamente depois que ele responder." }, "loadingEarlierMessages": "Carregando mensagens anteriores…", @@ -20742,8 +20742,15 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "Alternar para visualização de terminal", - "switchToChatView": "Alternar para visualização de chat" + "switchToTerminalView": "Continuar com o agente de terminal", + "switchToChatView": "Alternar para visualização de chat", + "showAsTerminal": "Mostrar como terminal" + }, + "switch": { + "stopAndSwitchTitle": "{{agent}} está respondendo. Interromper e alternar?", + "stopAndSwitch": "Interromper e alternar", + "stopUnknown": "O Orca não pôde confirmar que {{agent}} parou. Verifique os chats e terminais abertos e tente alternar novamente.", + "sourceStopUnknown": "O Orca não pôde confirmar que {{agent}} parou. Verifique {{agent}} no terminal original e tente alternar novamente." }, "BrowserTab": { "closeOthers": "Fechar Outras", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index ac49f108bdc1..7d313adf6568 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -19987,8 +19987,8 @@ }, "resumeInNewChat": { "hostUpdateRequired": "Обновите Orca на этом хосте, чтобы продолжить в чате.", - "exitUnconfirmedFromTerminal": "{{agent}} уже работает с этим разговором в другом терминале. Завершите {{agent}} там или закройте этот терминал, затем откройте разговор из истории сессий.", - "exitUnconfirmed": "{{agent}} уже работает с этим разговором в другом терминале. Завершите {{agent}} там или закройте этот терминал, затем откройте разговор из истории сессий.", + "exitUnconfirmedFromTerminal": "Orca не удалось подтвердить, что {{agent}} остановлен в этом терминале. Завершите {{agent}} здесь и повторите это действие.", + "exitUnconfirmed": "Orca не удалось подтвердить, что {{agent}} остановлен. Завершите {{agent}} в его терминале и повторите это действие.", "notSavedYet": "{{agent}} ещё не сохранил этот разговор. Повторите попытку после его ответа." }, "state": { @@ -20409,7 +20409,8 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Продолжить с агентом в терминале", - "switchToChatView": "Продолжить в чате" + "switchToChatView": "Продолжить в чате", + "showAsTerminal": "Показать как терминал" }, "BrowserTab": { "closeOthers": "Закрыть остальные", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 5cd5ff4c56af..70bfaec61176 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -19325,8 +19325,8 @@ }, "resumeInNewChat": { "hostUpdateRequired": "Sohbet arayüzünde devam etmek için bu sunucudaki Orca'yı güncelleyin.", - "exitUnconfirmedFromTerminal": "{{agent}} bu sohbeti zaten başka bir terminalde yürütüyor. O terminalde {{agent}} uygulamasından çıkın veya terminali kapatın, ardından bu sohbeti Oturum geçmişi bölümünden açın.", - "exitUnconfirmed": "{{agent}} bu sohbeti zaten başka bir terminalde yürütüyor. O terminalde {{agent}} uygulamasından çıkın veya terminali kapatın, ardından bu sohbeti Oturum geçmişi bölümünden açın.", + "exitUnconfirmedFromTerminal": "Orca, {{agent}} uygulamasının bu terminalde durduğunu doğrulayamadı. Burada {{agent}} uygulamasından çıkın ve bu işlemi yeniden deneyin.", + "exitUnconfirmed": "Orca, {{agent}} uygulamasının durduğunu doğrulayamadı. {{agent}} uygulamasından çalıştığı terminalde çıkın ve bu işlemi yeniden deneyin.", "notSavedYet": "{{agent}} bu sohbeti henüz kaydetmedi. Yanıt verdikten sonra yeniden deneyin." }, "state": { @@ -19729,7 +19729,8 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Terminal ajanıyla devam et", - "switchToChatView": "Sohbet arayüzünde devam et" + "switchToChatView": "Sohbet arayüzünde devam et", + "showAsTerminal": "Terminal olarak göster" }, "BrowserTab": { "closeOthers": "Diğerlerini Kapat", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 5582ef78eb5d..2ad9a66096a7 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -19042,8 +19042,8 @@ }, "resumeInNewChat": { "hostUpdateRequired": "請更新此主機上的 Orca,以便在聊天介面中繼續。", - "exitUnconfirmedFromTerminal": "{{agent}} 已在另一個終端機中執行此對話。請在該處結束 {{agent}} 或關閉該終端機,再從「工作階段歷史記錄」開啟此對話。", - "exitUnconfirmed": "{{agent}} 已在另一個終端機中執行此對話。請在該處結束 {{agent}} 或關閉該終端機,再從「工作階段歷史記錄」開啟此對話。", + "exitUnconfirmedFromTerminal": "Orca 無法確認 {{agent}} 已在此終端機中停止。請在此處結束 {{agent}},然後重試此操作。", + "exitUnconfirmed": "Orca 無法確認 {{agent}} 已停止。請在其所在的終端機中結束 {{agent}},然後重試此操作。", "notSavedYet": "{{agent}} 尚未儲存此對話。請在它回覆後重試。" }, "state": { @@ -19438,7 +19438,8 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "使用終端機代理程式繼續", - "switchToChatView": "在聊天介面中繼續" + "switchToChatView": "在聊天介面中繼續", + "showAsTerminal": "以終端機顯示" }, "BrowserTab": { "closeOthers": "關閉其他", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c29c20c3a07b..161da44dd08f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18667,8 +18667,8 @@ "resumeInNewChat": { "hostUpdateRequired": "请更新此主机上的 Orca 以在聊天界面中继续。", "notSavedYet": "{{agent}} 尚未保存此对话。请在它回复后重试。", - "exitUnconfirmed": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。", - "exitUnconfirmedFromTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后从会话历史记录中打开此对话。" + "exitUnconfirmed": "Orca 无法确认 {{agent}} 已停止。请在其所在的终端中退出 {{agent}},然后重试此操作。", + "exitUnconfirmedFromTerminal": "Orca 无法确认 {{agent}} 已在此终端中停止。请在此处退出 {{agent}},然后重试此操作。" }, "state": { "loading": { @@ -19149,7 +19149,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "使用终端智能体继续", - "switchToChatView": "在聊天界面中继续" + "switchToChatView": "以聊天显示", + "showAsTerminal": "以终端显示" + }, + "switch": { + "toCli": "切换到 CLI", + "toNativeChat": "切换到原生聊天", + "toCliTooltip": "切换到 CLI:停止原生聊天中的 {{agent}},并在终端中继续同一对话。", + "openedInWorkspace": "已在 {{workspace}} 中打开聊天。", + "toNativeChatTooltip": "切换到原生聊天:在此标签页的聊天中继续同一对话。", + "noConversation": "此终端中尚无可用的已保存对话。" }, "BrowserTab": { "closeOthers": "关闭其他", diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index c7bfea14e002..8826ff14d41e 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -1,3 +1,4 @@ +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' import { toast } from 'sonner' import type { AiVaultSession } from '../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' @@ -56,20 +57,47 @@ export const structuredSessionOpenFeedback: Pick< export async function activateAiVaultStructuredSession( session: Pick, deps?: StructuredSessionActivationDeps, - target?: RuntimeClientTarget + target?: RuntimeClientTarget, + viewerSelection?: string, + onFailure?: () => void ): Promise { + return ( + (await activateAiVaultStructuredSessionVerified( + session, + deps, + target, + viewerSelection, + onFailure + )) !== 'none' + ) +} + +/** `activated` only when the chat's tab is really active; `reported` means the click's toast said why + * not, which a caller retiring something on success must not read as success. */ +export async function activateAiVaultStructuredSessionVerified( + session: Pick, + deps?: StructuredSessionActivationDeps, + target?: RuntimeClientTarget, + viewerSelection?: string, + onFailure?: () => void +): Promise<'activated' | 'reported' | 'none'> { const structured = session.structuredSession if (!structured) { - return false + return 'none' } - const feedback = deps ?? structuredSessionOpenFeedback - await openExistingStructuredSession(structured, { + const feedback = onFailure + ? { unavailable: onFailure, gone: onFailure, hostCannotOpen: onFailure } + : (deps ?? structuredSessionOpenFeedback) + const modern = viewerSelection !== undefined && isSessionTabSelectionRequest(viewerSelection) + const outcome = await openExistingStructuredSession(structured, { target, deps, + viewerSelection, present: (outcome) => { if (outcome === 'activated') { // A moved project's chat opens under its retained entry; the catalog does not list it. if ( + !modern && useAppStore.getState().activeWorktreeId !== structured.workspaceId && !activateWslMovedWorkspace(structured.workspaceId) ) { @@ -79,10 +107,10 @@ export async function activateAiVaultStructuredSession( feedback.gone() } else if (outcome === 'host-cannot-open') { feedback.hostCannotOpen() - } else { + } else if (outcome === 'unavailable') { feedback.unavailable() } } }) - return true + return outcome === 'activated' || outcome === 'none' ? outcome : 'reported' } diff --git a/src/renderer/src/lib/agent-background-session-contract.ts b/src/renderer/src/lib/agent-background-session-contract.ts index bda9de8eac16..f1e0380b43bc 100644 --- a/src/renderer/src/lib/agent-background-session-contract.ts +++ b/src/renderer/src/lib/agent-background-session-contract.ts @@ -3,6 +3,7 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import type { PtyExitOptions } from '@/components/terminal-pane/pty-exit-delivery' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -13,7 +14,7 @@ export type LaunchAgentBackgroundSessionArgs = { launchSource?: LaunchSource title?: string onData?: (chunk: string) => void - onExit?: (ptyId: string, code: number) => void + onExit?: (ptyId: string, code: number, options?: PtyExitOptions) => void onAgentStatus?: (payload: ParsedAgentStatusPayload) => void } diff --git a/src/renderer/src/lib/agent-background-session-exit.ts b/src/renderer/src/lib/agent-background-session-exit.ts index 2de7a407a5b0..8f59b50c6dbb 100644 --- a/src/renderer/src/lib/agent-background-session-exit.ts +++ b/src/renderer/src/lib/agent-background-session-exit.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import type { PtyExitOptions } from '@/components/terminal-pane/pty-exit-delivery' import { isProvenProcessExit, UNVERIFIED_PROCESS_EXIT_CODE @@ -9,8 +10,16 @@ export function runtimeWaitExitCode(wait: { exitCode?: number | null }): number return wait.exitCode ?? UNVERIFIED_PROCESS_EXIT_CODE } -/** Drop tab/PTY identity only on proven exit (as in pty-exit-hibernate); synthetic loss retires transport but preserves reconnect adoption and guards against orphan cleanup. */ -export function settleTabPtyBinding(tabId: string, ptyId: string, code: number): void { +/** Retained exits settle execution without dropping presentation; ordinary synthetic loss preserves reconnect adoption. */ +export function settleTabPtyBinding( + tabId: string, + ptyId: string, + code: number, + options?: PtyExitOptions +): void { + if (options?.preserveRendererBinding === true || options?.preserveRendererBinding === 'closing') { + return + } const state = useAppStore.getState() if (isProvenProcessExit(code)) { state.clearTabPtyId(tabId, ptyId) diff --git a/src/renderer/src/lib/agent-launch-pane-closes.ts b/src/renderer/src/lib/agent-launch-pane-closes.ts index cc8e15f6a66c..a6ed4bbf1196 100644 --- a/src/renderer/src/lib/agent-launch-pane-closes.ts +++ b/src/renderer/src/lib/agent-launch-pane-closes.ts @@ -1,6 +1,9 @@ /** Remember explicit user closes so a racing spawn reveal stops the agent instead of resurrecting its tab; absence after reload is not a close. Keep store-free for teardown callers. */ -import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' +import { + settleAgentLaunchPaneStart, + forgetAgentLaunchPaneAttempts +} from './agent-launch-pane-start' const MAX_REMEMBERED_CLOSES = 32 const closedPaneKeys = new Set() @@ -12,6 +15,7 @@ function closeKey(tabId: string, leafId: string): string { export function noteAgentLaunchPaneClosedByUser(tabId: string, leafId: string): void { settleAgentLaunchPaneStart(tabId, leafId, null) + forgetAgentLaunchPaneAttempts(tabId, leafId) const paneKey = closeKey(tabId, leafId) closedPaneKeys.delete(paneKey) closedPaneKeys.add(paneKey) diff --git a/src/renderer/src/lib/agent-launch-pane-ordinary-verdict.ts b/src/renderer/src/lib/agent-launch-pane-ordinary-verdict.ts new file mode 100644 index 000000000000..6ab4340ebcf0 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-ordinary-verdict.ts @@ -0,0 +1,75 @@ +/** Apply main’s typed spawn/takeback verdicts, retaining final pane outcomes until session cleanup. */ + +import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict' +import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { useAppStore } from '@/store' +import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +import { + agentLaunchPaneAttemptCurrent, + settleAgentLaunchPaneStart +} from './agent-launch-pane-start' +import { + applyVerdictToUndrawnLaunchTab, + releaseMainWrittenLaunchTab +} from './agent-launch-tab-main-written' + +export function applyOrdinaryAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): boolean { + if (!agentLaunchPaneAttemptCurrent(event.tabId, event.leafId, event.operationId)) { + return false + } + const { worktreeId, tabId, leafId, verdict } = event + const state = useAppStore.getState() + const tab = state.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + // A main-written tab this window does not draw yet keeps the verdict until it draws. + if (!tab && applyVerdictToUndrawnLaunchTab(event)) { + return true + } + // Only a pane its tab says a launch laid out: a verdict never touches anything else. + if ( + tab?.agentLaunchPane?.leafId !== leafId || + tab.agentLaunchPane.operationId !== event.operationId + ) { + return false + } + if (verdict.kind !== 'proceed') { + settleAgentLaunchPaneStart(tabId, leafId, null) + } + switch (verdict.kind) { + case 'proceed': + releaseMainWrittenLaunchTab(tabId) + forgetAgentLaunchPanePrompt(tabId) + state.setTabAgentLaunchPane(tabId, undefined) + return true + case 'not-started': + case 'unconfirmed': { + const root = state.terminalLayoutsByTabId[tabId]?.root + if (root && !collectLeafIdsInOrder(root).includes(leafId)) { + forgetAgentLaunchPanePrompt(tabId) + state.setTabAgentLaunchPane(tabId, undefined) + return true + } + state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict }) + return true + } + case 'withdrawn': { + forgetAgentLaunchPanePrompt(tabId) + if (releaseMainWrittenLaunchTab(tabId)) { + state.setTabAgentLaunchPane(tabId, undefined) + return true + } + // The pane alone when the user split the tab while it waited; their split stays. + if (state.terminalLayoutsByTabId[tabId]?.root?.type === 'leaf') { + state.closeTab(tabId, { + reason: 'cleanup', + recordInteraction: false, + captureRecentlyClosed: false + }) + } else { + state.setTabAgentLaunchPane(tabId, undefined) + applyClosedTerminalLeafNotice(tabId, leafId) + } + } + } + return true +} diff --git a/src/renderer/src/lib/agent-launch-pane-start.ts b/src/renderer/src/lib/agent-launch-pane-start.ts index 216d2aa1f57c..61df9d7cf63f 100644 --- a/src/renderer/src/lib/agent-launch-pane-start.ts +++ b/src/renderer/src/lib/agent-launch-pane-start.ts @@ -1,6 +1,9 @@ // Session-only: this pane's own spawn answers first, and its answer gates the prompt's paste. -const pendingStarts = new Map void>() +const pendingStarts = new Map< + string, + { resolve?: (startedPtyId: string | null) => void; operationId?: string; awaitingHost?: boolean } +>() // Not `makePaneKey`: that validates the leaf id, and a launch must never fail over bookkeeping. function startKey(tabId: string, leafId: string): string { @@ -10,9 +13,9 @@ function startKey(tabId: string, leafId: string): string { /** Registered before the pane exists, so its first spawn already answers it: with its PTY, or null. */ export function awaitAgentLaunchPaneStart(tabId: string, leafId: string): Promise { const key = startKey(tabId, leafId) - pendingStarts.get(key)?.(null) + pendingStarts.get(key)?.resolve?.(null) return new Promise((resolve) => { - pendingStarts.set(key, resolve) + pendingStarts.set(key, { ...pendingStarts.get(key), resolve }) }) } @@ -24,7 +27,55 @@ export function settleAgentLaunchPaneStart( if (tabId && leafId) { const key = startKey(tabId, leafId) const settle = pendingStarts.get(key) - pendingStarts.delete(key) - settle?.(startedPtyId) + const resolve = settle?.resolve + if (settle) { + settle.resolve = undefined + } + resolve?.(startedPtyId) + } +} + +/** New addressed work wins before launch preparation or a late old verdict. */ +export function acceptAgentLaunchPaneAttempt(paneKey: string, operationId?: string): void { + const split = paneKey.indexOf(':') + if (split === -1) { + return + } + const key = startKey(paneKey.slice(0, split), paneKey.slice(split + 1)) + const entry = pendingStarts.get(key) + entry?.resolve?.(null) + pendingStarts.set(key, { operationId, awaitingHost: true }) +} +export function confirmAgentLaunchPaneAttempt( + tabId: string, + leafId: string, + operationId?: string +): void { + const key = startKey(tabId, leafId) + const current = pendingStarts.get(key) + if (current?.awaitingHost && current.operationId !== operationId) { + return + } + if (current?.operationId !== operationId) { + current?.resolve?.(null) + } + pendingStarts.set(key, { ...current, operationId, awaitingHost: false }) +} + +export function agentLaunchPaneAttemptCurrent( + tabId: string, + leafId: string, + operationId?: string +): boolean { + const entry = pendingStarts.get(startKey(tabId, leafId)) + return !entry || entry.operationId === operationId +} + +export function forgetAgentLaunchPaneAttempts(tabId: string, leafId?: string): void { + for (const [key, entry] of pendingStarts) { + if (JSON.parse(key)[0] === tabId && (leafId === undefined || JSON.parse(key)[1] === leafId)) { + entry.resolve?.(null) + pendingStarts.delete(key) + } } } diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts index 0fff7f97a1f8..d14bd2351ee2 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts @@ -52,7 +52,7 @@ function launchTab() { } function apply(verdict: AgentLaunchPaneVerdict, leafId = LEAF): void { - applyAgentLaunchPaneVerdict({ worktreeId: WT, tabId, leafId, verdict }) + applyAgentLaunchPaneVerdict({ worktreeId: WT, tabId, leafId, operationId: 'op-1', verdict }) } beforeEach(() => { diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts index e8d4bbd59f97..4a4642eb5662 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -1,65 +1,118 @@ -/** Apply main’s typed spawn/takeback verdicts, retaining final pane outcomes until session cleanup. */ - +import { endViewerSelectionForDestination } from './viewer-selection-request' +import { applyOrdinaryAgentLaunchPaneVerdict } from './agent-launch-pane-ordinary-verdict' +import { launchTabWithdrawalIdentity } from '../../../shared/agent-launch-tab-publication' import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict' -import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' -import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { createKeptWorktreeSurfacePruners } from '@/store/slices/tabs/kept-worktree-surface-pruning' import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' -import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' +import { + agentLaunchPaneAttemptCurrent, + confirmAgentLaunchPaneAttempt, + settleAgentLaunchPaneStart +} from './agent-launch-pane-start' import { applyVerdictToUndrawnLaunchTab, releaseMainWrittenLaunchTab } from './agent-launch-tab-main-written' -export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { - const { worktreeId, tabId, leafId, verdict } = event - const state = useAppStore.getState() - const tab = state.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) - // A main-written tab this window does not draw yet keeps the verdict until it draws. - if (!tab && applyVerdictToUndrawnLaunchTab(event)) { - return - } - // Only a pane its tab says a launch laid out: a verdict never touches anything else. - if (tab?.agentLaunchPane?.leafId !== leafId) { - return +/** Apply main’s typed spawn/takeback verdicts, retaining final pane outcomes until session cleanup. */ + +export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): boolean { + if (event.accepted) { + confirmAgentLaunchPaneAttempt(event.tabId, event.leafId, event.operationId) + return true } - if (verdict.kind !== 'proceed') { - settleAgentLaunchPaneStart(tabId, leafId, null) + if (event.withdrawalRoot === undefined) { + const applied = applyOrdinaryAgentLaunchPaneVerdict(event) + if (applied && (event.verdict.kind === 'withdrawn' || event.verdict.kind === 'not-started')) { + endViewerSelectionForDestination(event.worktreeId, event.tabId) + } + return applied } - switch (verdict.kind) { - case 'proceed': - releaseMainWrittenLaunchTab(tabId) - forgetAgentLaunchPanePrompt(tabId) - state.setTabAgentLaunchPane(tabId, undefined) - return - case 'not-started': - case 'unconfirmed': { + const { worktreeId, tabId, leafId, verdict } = event + // Commit the state verdict first; never call ordinary Close on a launch's provisional pane. + let accepted = false + let withdrawn = false + useAppStore.setState((state) => { + if (!agentLaunchPaneAttemptCurrent(tabId, leafId, event.operationId)) { + return state + } + const tab = state.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + if (!tab) { + accepted = applyVerdictToUndrawnLaunchTab(event) + return state + } + if ( + tab.agentLaunchPane?.leafId !== leafId || + tab.agentLaunchPane.operationId !== event.operationId + ) { + return state + } + if (verdict.kind === 'withdrawn') { const root = state.terminalLayoutsByTabId[tabId]?.root - if (root && !collectLeafIdsInOrder(root).includes(leafId)) { - forgetAgentLaunchPanePrompt(tabId) - state.setTabAgentLaunchPane(tabId, undefined) - return + if ( + (event.withdrawalRoot !== undefined && + event.withdrawalRoot !== + launchTabWithdrawalIdentity( + root, + state.unifiedTabsByWorktree[worktreeId] ?? [], + state.groupsByWorktree[worktreeId] ?? [], + tabId + )) || + root?.type !== 'leaf' || + root.leafId !== leafId || + state.ptyIdsByTabId[tabId]?.length || + state.lastTerminalInputAtByPaneKey[`${tabId}:${leafId}`] + ) { + return state } - state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict }) - return - } - case 'withdrawn': { - forgetAgentLaunchPanePrompt(tabId) - if (releaseMainWrittenLaunchTab(tabId)) { - state.setTabAgentLaunchPane(tabId, undefined) - return + const prune = createKeptWorktreeSurfacePruners(state, { + worktreeIds: new Set([worktreeId]), + terminalTabIds: new Set([tabId]), + unifiedTabIds: new Set(), + fileIds: new Set(), + browserTabIds: new Set() + }) + accepted = withdrawn = true + endViewerSelectionForDestination(worktreeId, tabId) + const layouts = { ...state.terminalLayoutsByTabId } + delete layouts[tabId] + return { + tabsByWorktree: prune.tabs(state.tabsByWorktree), + unifiedTabsByWorktree: prune.unifiedTabs(state.unifiedTabsByWorktree), + groupsByWorktree: prune.groups(state.groupsByWorktree), + tabBarOrderByWorktree: prune.tabBarOrder(state.tabBarOrderByWorktree), + activeTabIdByWorktree: prune.activeTabIds(state.activeTabIdByWorktree), + terminalLayoutsByTabId: layouts } - // The pane alone when the user split the tab while it waited; their split stays. - if (state.terminalLayoutsByTabId[tabId]?.root?.type === 'leaf') { - state.closeTab(tabId, { - reason: 'cleanup', - recordInteraction: false, - captureRecentlyClosed: false - }) - } else { - state.setTabAgentLaunchPane(tabId, undefined) - applyClosedTerminalLeafNotice(tabId, leafId) + } + accepted = true + return { + tabsByWorktree: { + ...state.tabsByWorktree, + [worktreeId]: state.tabsByWorktree[worktreeId].map((row) => + row !== tab + ? row + : { + ...row, + agentLaunchPane: + verdict.kind === 'proceed' + ? undefined + : { ...tab.agentLaunchPane!, outcome: verdict } + } + ) } } + }) + if (!accepted) { + return false + } + if (verdict.kind !== 'proceed') { + settleAgentLaunchPaneStart(tabId, leafId, null) + } + if (withdrawn || verdict.kind === 'proceed') { + releaseMainWrittenLaunchTab(tabId) + forgetAgentLaunchPanePrompt(tabId) } + return true } diff --git a/src/renderer/src/lib/agent-launch-tab-main-written.test.ts b/src/renderer/src/lib/agent-launch-tab-main-written.test.ts index 2984b504ad47..949e11d02fb9 100644 --- a/src/renderer/src/lib/agent-launch-tab-main-written.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-main-written.test.ts @@ -1,3 +1,11 @@ +import { + beginViewerSelectionRequest, + disposeViewerSelection, + viewerSelectionOutcome +} from './viewer-selection-request' +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { noteUserWorkspaceSwitch } from './workspace-switch-user-act' +import { _resetLayoutFramePositionForTests } from '@/runtime/layout-frame-position' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { AgentLaunchTabPublishRequest } from '../../../shared/agent-launch-tab-publication' @@ -24,6 +32,8 @@ vi.mock('@/store', () => { return { useAppStore: { getState: () => current().getState(), + setState: (...args: Parameters['setState']>) => + current().setState(...args), subscribe: (listener: () => void) => current().subscribe(listener) } } @@ -87,12 +97,14 @@ const launchPane = () => store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.agentLaunchPane beforeEach(() => { + _resetLayoutFramePositionForTests() store = createTestStore() testStore.current = store focusTerminalInitiatedTab.mockClear() store.getState().setActiveWorktree(WT) }) afterEach(() => { + disposeViewerSelection() _resetUndrawnLaunchTabsForTests() frames.flowing = false frames.holdsTab = true @@ -131,6 +143,7 @@ describe("a launch tab main wrote, in a layout-owner build's window", () => { worktreeId: WT, tabId: TAB_ID, leafId: LEAF_ID, + operationId: 'op-1', verdict: { kind: 'not-started', code: 'boom' } }) @@ -147,6 +160,7 @@ describe("a launch tab main wrote, in a layout-owner build's window", () => { worktreeId: WT, tabId: TAB_ID, leafId: LEAF_ID, + operationId: 'op-1', verdict: { kind: 'unconfirmed' } }) @@ -165,6 +179,7 @@ describe("a launch tab main wrote, in a layout-owner build's window", () => { worktreeId: WT, tabId: TAB_ID, leafId: LEAF_ID, + operationId: 'op-1', verdict: { kind: 'proceed' } }) @@ -182,6 +197,7 @@ describe("a launch tab main wrote, in a layout-owner build's window", () => { worktreeId: WT, tabId: TAB_ID, leafId: LEAF_ID, + operationId: 'op-1', verdict: { kind: 'withdrawn' } }) @@ -212,3 +228,59 @@ describe("a launch tab main wrote, in a layout-owner build's window", () => { expect(agentLaunchPanePrompt(TAB_ID)).toBeNull() }) }) + +it.each([false, true])( + 'the actual undrawn callback fulfils once, preserving a newer user choice=%s', + (navigate) => { + const other = store.getState().createTab(WT) + const deadline = agentSessionSwitchDeadline(Date.now) + try { + const older = beginViewerSelectionRequest('older-dialog', deadline) + const selection = beginViewerSelectionRequest('accepted-action', deadline) + selection.accept() + older.accept() + expect(viewerSelectionOutcome(selection.token)).toBe('pending') + keepAgentLaunchTabMainWrote( + request({ + viewer: 'focus-window', + openPlacement: { + invocationId: 'accepted-action', + generation: 'op-1', + placement: {}, + activate: true, + viewerSelection: selection.token + } + }) + ) + expect(viewerSelectionOutcome(selection.token)).toBe('pending') + expect(revealWorkspaceSurface).not.toHaveBeenCalled() + if (navigate) { + noteUserWorkspaceSwitch(WT) + store + .getState() + .activateTab( + store.getState().unifiedTabsByWorktree[WT].find((tab) => tab.entityId === other.id)!.id + ) + } + drawMainTab() + expect(viewerSelectionOutcome(selection.token)).toBe(navigate ? 'superseded' : 'fulfilled') + expect(store.getState().getActiveTab(WT)?.entityId).toBe(navigate ? other.id : TAB_ID) + const focusCount = focusTerminalInitiatedTab.mock.calls.length + keepAgentLaunchTabMainWrote( + request({ + viewer: 'focus-window', + openPlacement: { + invocationId: 'accepted-action', + generation: 'op-1', + placement: {}, + activate: true, + viewerSelection: selection.token + } + }) + ) + expect(focusTerminalInitiatedTab).toHaveBeenCalledTimes(focusCount) + } finally { + deadline.dispose() + } + } +) diff --git a/src/renderer/src/lib/agent-launch-tab-main-written.ts b/src/renderer/src/lib/agent-launch-tab-main-written.ts index 9ce97730d3a5..1fc811c52462 100644 --- a/src/renderer/src/lib/agent-launch-tab-main-written.ts +++ b/src/renderer/src/lib/agent-launch-tab-main-written.ts @@ -1,5 +1,12 @@ -/** Main owns tab creation/closure; attach this window’s session-only prompt, pane verdict and presentation state when its frame draws, retaining them for undrawn workspaces. */ - +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { openPlacementSourceCurrent } from '@/runtime/web-session-tabs-sync/open-placement' +import { + bindViewerSelectionDestination, + endViewerSelectionForDestination +} from './viewer-selection-request' +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' +import { agentLaunchPaneAttemptCurrent } from './agent-launch-pane-start' +import { afterLayoutFrame, layoutFramesFlowing } from '@/runtime/layout-frame-position' import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict' import type { AgentLaunchTabPublished, @@ -13,9 +20,10 @@ import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' import { revealWorkspaceSurface } from './workspace-surface-reveal' -import { layoutFramesFlowing } from '@/runtime/layout-frame-position' import { layoutFrameTerminalTab } from '@/runtime/layout-frame-terminal-panes' +/** Main owns tab creation/closure; attach this window’s session-only prompt, pane verdict and presentation state when its frame draws, retaining them for undrawn workspaces. */ + type LaunchPane = NonNullable const undrawn = new Map void; stop: () => void }>() @@ -76,57 +84,80 @@ function goneFromFrames(worktreeId: string, tabId: string): boolean { function keepWhenDrawn(request: AgentLaunchTabPublishRequest, present: () => void): boolean { const { worktreeId, tabId } = request undrawn.get(tabId)?.stop() - if (goneFromFrames(worktreeId, tabId)) { - dropUndrawn(tabId) - return false + + const forgetIfGone = () => { + if ( + undrawn.get(tabId)?.pane.operationId === request.operationId && + goneFromFrames(worktreeId, tabId) + ) { + endViewerSelectionForDestination(worktreeId, tabId) + dropUndrawn(tabId) + } } const stop = useAppStore.subscribe(() => { const tab = drawnTab(worktreeId, tabId) if (!tab) { - if (goneFromFrames(worktreeId, tabId)) { - dropUndrawn(tabId) - } + afterLayoutFrame(request.shownByMain?.frameSeq, forgetIfGone) return } const entry = undrawn.get(tabId) undrawn.delete(tabId) stop() - if (entry) { + if (entry && agentLaunchPaneAttemptCurrent(tabId, entry.pane.leafId, entry.pane.operationId)) { keepLaunchPane(tabId, tab, entry.pane) entry.present() } }) undrawn.set(tabId, { pane: launchPaneOf(request), present, stop }) - return true + if (request.shownByMain) { + afterLayoutFrame(request.shownByMain.frameSeq, forgetIfGone) + } + return undrawn.has(tabId) } export function keepAgentLaunchTabMainWrote( request: AgentLaunchTabPublishRequest ): AgentLaunchTabPublished { const { worktreeId, tabId, leafId, viewer } = request + if (!agentLaunchPaneAttemptCurrent(tabId, leafId, request.operationId)) { + throw new Error('agent_launch_superseded') + } const created = request.shownByMain?.created === true + const viewerSelection = request.openPlacement?.viewerSelection ?? request.viewerSelection + bindViewerSelectionDestination(viewerSelection, { worktreeId, tabId }) noteMainWritten(tabId) if (request.prompt) { rememberAgentLaunchPanePrompt(tabId, request.prompt) } - // Only the launch that made the tab moves anyone, as the window's own publication. + // Ordinary retries retain focus; explicit viewer requests use their one-shot owner. const present = (): void => { - if (created) { - presentAgentLaunchTab(viewer, worktreeId, tabId, leafId) + if ( + (created || isSessionTabSelectionRequest(viewerSelection)) && + (!request.openPlacement || + (request.openPlacement.activate && + openPlacementSourceCurrent(useAppStore.getState(), worktreeId, request.openPlacement))) + ) { + presentAgentLaunchTab(viewer, worktreeId, tabId, leafId, viewerSelection) } } const tab = drawnTab(worktreeId, tabId) if (tab) { keepLaunchPane(tabId, tab, launchPaneOf(request)) present() - } else if (created && viewer === 'focus-window') { + } else if ( + created && + viewer === 'focus-window' && + conversationSwitchViewerSelectionCurrent(viewerSelection) + ) { // Reveal the workspace while awaiting its tab frame, unless main already closed the tab. if (keepWhenDrawn(request, present)) { - revealWorkspaceSurface(useAppStore.getState(), worktreeId, [tabId]) + if (viewerSelection === undefined) { + revealWorkspaceSurface(useAppStore.getState(), worktreeId, [tabId]) + } } } else { // Nobody is moved to an undrawn workspace; its tab keeps the launch when it draws. - keepWhenDrawn(request, () => {}) + keepWhenDrawn(request, present) } const groupId = useAppStore @@ -137,15 +168,22 @@ export function keepAgentLaunchTabMainWrote( /** Retain verdicts for undrawn launch panes; return false when no such pane is pending. */ export function applyVerdictToUndrawnLaunchTab(event: AgentLaunchPaneVerdictEvent): boolean { + if (!agentLaunchPaneAttemptCurrent(event.tabId, event.leafId, event.operationId)) { + return false + } const entry = undrawn.get(event.tabId) - if (entry?.pane.leafId !== event.leafId) { + if (entry?.pane.leafId !== event.leafId || entry.pane.operationId !== event.operationId) { return false } const { verdict } = event + if (verdict.kind === 'not-started') { + endViewerSelectionForDestination(event.worktreeId, event.tabId) + } if (verdict.kind === 'not-started' || verdict.kind === 'unconfirmed') { entry.pane = { ...entry.pane, outcome: verdict } return true } + endViewerSelectionForDestination(event.worktreeId, event.tabId) dropUndrawn(event.tabId) return true } diff --git a/src/renderer/src/lib/agent-launch-tab-presentation.ts b/src/renderer/src/lib/agent-launch-tab-presentation.ts index 90cfdfd39e36..e4b33b0f6290 100644 --- a/src/renderer/src/lib/agent-launch-tab-presentation.ts +++ b/src/renderer/src/lib/agent-launch-tab-presentation.ts @@ -1,3 +1,6 @@ +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { selectSessionTabForViewer } from '@/runtime/web-session-tabs-sync/store-patch' +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' import type { AgentLaunchTabViewerRule } from '../../../shared/agent-launch-tab-publication' import { focusTerminalInitiatedTab } from '../hooks/ipc-events/terminal-command-state' import { useAppStore } from '../store' @@ -8,8 +11,24 @@ export function presentAgentLaunchTab( viewer: AgentLaunchTabViewerRule, worktreeId: string, tabId: string, - leafId?: string + leafId?: string, + viewerSelection?: string ): void { + if (!conversationSwitchViewerSelectionCurrent(viewerSelection)) { + return + } + if (viewerSelection && isSessionTabSelectionRequest(viewerSelection)) { + const state = useAppStore.getState() + if ( + viewer === 'focus-window' || + (viewer === 'focus-in-workspace' && state.activeWorktreeId === worktreeId) + ) { + if (selectSessionTabForViewer(worktreeId, tabId, viewerSelection)) { + focusTerminalInitiatedTab(tabId, leafId, worktreeId) + } + } + return + } if (viewer === 'focus-window') { revealWorkspaceSurface(useAppStore.getState(), worktreeId, [tabId]) } diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts index baec5addfdc5..90bf6c5e389f 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { forgetAgentLaunchPaneAttempts } from './agent-launch-pane-start' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { disposeSwitchProjections } from './conversation-switch-projection' import type * as AgentStatusModule from '@/lib/agent-status' import type { AgentLaunchTabPublishRequest } from '../../../shared/agent-launch-tab-publication' import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' @@ -16,6 +19,8 @@ const testStore = vi.hoisted(() => { }) vi.mock('@/store', () => ({ useAppStore: { + setState: (patch: Partial | ((state: AppState) => Partial)) => + testStore.current!.setState(patch), getState: () => { if (!testStore.current) { throw new Error('no test store') @@ -62,11 +67,13 @@ function launchTab() { } beforeEach(() => { + forgetAgentLaunchPaneAttempts(TAB_ID) store = createTestStore() testStore.current = store focusTerminalInitiatedTab.mockClear() store.getState().setActiveWorktree(WT) }) +afterEach(() => disposeSwitchProjections()) describe('publishing a launch tab before its agent exists', () => { it('creates the tab under the host ids with its one pane laid out and no process', () => { diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index 05492aacb766..214b1a448d11 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -1,5 +1,8 @@ -/** Publish under host-reserved IDs before the agent exists so PTY routing names this pane and retries reuse its tab; the pane’s spawn waits in main and attaches to the host’s process. */ - +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { conversationSwitchViewerSelectionCurrent } from '@/lib/conversation-switch-selection' +import { applyAgentLaunchPaneVerdict } from './agent-launch-pane-verdict-application' +import { launchTabWithdrawalIdentity } from '../../../shared/agent-launch-tab-publication' +import { agentLaunchPaneAttemptCurrent } from './agent-launch-pane-start' import type { AgentLaunchPlacementReceipt } from '../../../shared/agent-launch-intent' import type { AgentLaunchTabPublished, @@ -12,6 +15,9 @@ import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-crea import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' +import { openPlacementSourceCurrent } from '@/runtime/web-session-tabs-sync/open-placement' + +/** Publish under host-reserved IDs before the agent exists so PTY routing names this pane and retries reuse its tab; the pane’s spawn waits in main and attaches to the host’s process. */ function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -45,6 +51,24 @@ export function publishAgentLaunchTab( request: AgentLaunchTabPublishRequest ): AgentLaunchTabPublished { const { worktreeId, tabId, leafId } = request + if (request.withdraw) { + return { + tabId, + created: false, + placement: { groupId: '' }, + withdrawn: applyAgentLaunchPaneVerdict({ + worktreeId, + tabId, + leafId, + operationId: request.operationId, + verdict: { kind: 'withdrawn' }, + withdrawalRoot: request.withdraw.root + }) + } + } + if (!agentLaunchPaneAttemptCurrent(tabId, leafId, request.operationId)) { + throw new Error('agent_launch_superseded') + } const store = useAppStore.getState() const owner = Object.entries(store.tabsByWorktree).find(([, tabs]) => tabs.some((tab) => tab.id === tabId) @@ -69,34 +93,63 @@ export function publishAgentLaunchTab( remount: kept?.leafId === leafId && kept.outcome !== undefined }) } + const retrySelection = request.openPlacement?.viewerSelection ?? request.viewerSelection + if ( + isSessionTabSelectionRequest(retrySelection) && + (!request.openPlacement || + (request.openPlacement.activate && + openPlacementSourceCurrent(store, worktreeId, request.openPlacement))) + ) { + presentAgentLaunchTab( + request.viewer, + worktreeId, + tabId, + leafId, + request.openPlacement?.viewerSelection ?? request.viewerSelection + ) + } return { tabId, created: false, placement: { groupId } } } + const openPlacement = + request.openPlacement && openPlacementSourceCurrent(store, worktreeId, request.openPlacement) + ? request.openPlacement + : undefined + const requestedPlacement = request.openPlacement ? openPlacement?.placement : request.placement + const viewerSelection = openPlacement?.viewerSelection ?? request.viewerSelection const placement = resolveUnifiedTabCreatePlacement({ groups: store.groupsByWorktree[worktreeId] ?? [], tabs: store.unifiedTabsByWorktree[worktreeId] ?? [], activeGroupId: store.activeGroupIdByWorktree[worktreeId], - targetGroupId: request.placement?.groupId, - afterTabId: request.placement?.afterTabId, + targetGroupId: requestedPlacement?.groupId, + afterTabId: requestedPlacement?.afterTabId, lookupWorktrees: () => [ ...(store.allWorktrees?.() ?? []), ...(selectFolderWorkspaces(store) ?? []).map(folderWorkspaceToWorktree) ].filter((worktree) => worktree.id === worktreeId) }) - const focuses = request.viewer === 'focus-window' || request.viewer === 'focus-in-workspace' + const focuses = + (request.viewer === 'focus-window' || request.viewer === 'focus-in-workspace') && + (!request.openPlacement || openPlacement?.activate === true) && + conversationSwitchViewerSelectionCurrent(viewerSelection) const tab = store.createTab(worktreeId, placement.groupId, undefined, { id: tabId, initialLeafId: leafId, agentLaunchPane: launchPaneFor(request), launchAgent: request.launchAgent, viewMode: request.viewMode, - ...(focuses ? {} : { activate: false, recordInteraction: false }) + ...(openPlacement + ? { placement: { ...openPlacement.placement, afterTabId: placement.anchorTabId } } + : {}), + ...(focuses && viewerSelection === undefined + ? {} + : { activate: false, recordInteraction: false }) }) if (request.prompt) { rememberAgentLaunchPanePrompt(tabId, request.prompt) } - if (placement.anchorTabId) { + if (placement.anchorTabId && !request.openPlacement) { const unifiedTabId = useAppStore .getState() .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id)?.id @@ -104,12 +157,22 @@ export function publishAgentLaunchTab( insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, placement.anchorTabId) } } - presentAgentLaunchTab(request.viewer, worktreeId, tab.id, leafId) + if (focuses) { + presentAgentLaunchTab(request.viewer, worktreeId, tab.id, leafId, viewerSelection) + } else if (!request.openPlacement) { + presentAgentLaunchTab(request.viewer, worktreeId, tab.id, leafId, viewerSelection) + } const landed = landedGroupId(worktreeId, tab.id) ?? placement.groupId ?? '' const fallback = placementFallback(request, landed, placement.anchorTabId !== undefined) return { tabId: tab.id, created: true, + withdrawalRoot: launchTabWithdrawalIdentity( + useAppStore.getState().terminalLayoutsByTabId[tab.id]?.root, + useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [], + useAppStore.getState().groupsByWorktree[worktreeId] ?? [], + tab.id + ), placement: { groupId: landed, ...(fallback ? { fallback } : {}) } } } diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index 0b2d6222fde3..dc43b6bb1ebe 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -18,6 +18,12 @@ const testStore = vi.hoisted(() => { }) vi.mock('@/store', () => ({ useAppStore: { + setState: (patch: Parameters['setState']>[0]) => { + if (!testStore.current) { + throw new Error('no test store') + } + testStore.current.setState(patch) + }, getState: () => { if (!testStore.current) { throw new Error('no test store') diff --git a/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.test.ts index be96646180c3..7a478a9f9cc0 100644 --- a/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.test.ts @@ -1,4 +1,17 @@ // @vitest-environment happy-dom +import { noteUserWorkspaceSwitch } from '@/lib/workspace-switch-user-act' +import { getDefaultSettings } from '../../../../shared/constants' +import type * as CliChatResumeSourceModule from '@/components/tab-bar/cli-chat-resume-source' +import type * as CliChatResumeTerminalCapture from '@/components/tab-bar/cli-chat-resume-terminal-capture' +import type { CliChatResumeSource } from '@/components/tab-bar/cli-chat-resume-source' +import { runConversationSwitch } from '../conversation-surface-switch' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import type * as SessionClient from '@/runtime/structured-agent-session-client' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../../shared/agent-session-wire' +import type { StructuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/index' import type * as launchModule from '../structured-agent-session-launch-create-call' @@ -31,22 +44,96 @@ import { type TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture' -vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => - ( +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const rpc = ( await import('@/runtime/layout-apply-test-transport.test-fixture') ).runtimeRpcClientWithLayoutApply(await importOriginal()) -) + return { + ...rpc, + callRuntimeRpc: (...args: Parameters) => { + if (mocks.source.current && args[1] === 'terminal.stopExact') { + mocks.stopped(args[2]) + return Promise.resolve({ stoppedPtyIds: ['source-pty'], postStopVerified: true }) + } + return rpc.callRuntimeRpc(...args) + } + } +}) +vi.mock('@/runtime/structured-agent-session-client', async (original) => { + const client = await original() + return { + ...client, + supportsStructuredAgentSessionCreateMessage: ( + ...args: Parameters + ) => + mocks.source.current + ? Promise.resolve(false) + : client.supportsStructuredAgentSessionCreateMessage(...args), + callStructuredAgentSession: (...args: Parameters) => { + if (mocks.source.current && args[1] === 'agentSession.createSupport') { + return mocks.support() + } + if (mocks.source.current && args[1] === 'agentSession.create') { + return mocks.create(args[2]) + } + return client.callStructuredAgentSession(...args) + } + } +}) +vi.mock('@/components/tab-bar/cli-chat-resume-source', async (original) => { + const actual = await original() + return { + ...actual, + resolveCliChatResumeSource: (...args: Parameters) => + mocks.source.current ?? actual.resolveCliChatResumeSource(...args) + } +}) +vi.mock('@/components/tab-bar/cli-chat-resume-terminal-capture', async (original) => { + const actual = await original() + return { + ...actual, + captureSourceTerminal: (...args: Parameters) => + mocks.source.current + ? Promise.resolve({ + target: { kind: 'local' }, + handle: 'source-handle', + incarnationId: 'source-incarnation', + presentation: { identity: { ptyId: 'source-pty' } } + }) + : actual.captureSourceTerminal(...args) + } +}) vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => ( await import('@/runtime/layout-apply-test-transport.test-fixture') ).runtimeLayoutClientWithTestFrames(await importOriginal()) ) -const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) -vi.mock('../structured-agent-session-launch-create-call', async (original) => ({ - ...(await original()), - launchStructuredAgentSession: mocks.launch -})) +const mocks = vi.hoisted(() => { + function makeSource(): { current: CliChatResumeSource | null } { + return { current: null } + } + return { + text: '', + focus: vi.fn(), + toast: vi.fn(), + launch: vi.fn(), + create: vi.fn(), + support: vi.fn(), + stopped: vi.fn(), + source: makeSource() + } +}) +vi.mock('../structured-agent-session-launch-create-call', async (original) => { + const actual = await original() + return { + ...actual, + launchStructuredAgentSession: ( + ...args: Parameters + ) => + mocks.source.current ? actual.launchStructuredAgentSession(...args) : mocks.launch(...args) + } +}) vi.mock('sonner', () => ({ toast: { info: mocks.toast, error: vi.fn() } })) vi.mock('../focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ @@ -89,6 +176,8 @@ let main: TabsDrawnFromMain beforeEach(async () => { vi.clearAllMocks() mocks.text = '' + mocks.source.current = null + setLocalRuntimeCapabilitiesForTests([]) resetStructuredAgentLaunchRegistryForTests() clearNativeChatPendingAttachmentsForTests() useAppStore.setState({ @@ -446,3 +535,109 @@ it.each(['expiry', 'purge'])('created pane publication: %s', async (order) => { ) } }) + +it.each([false, true])( + 'strict legacy Stop through real host admission and provisional/layout writers, newer navigation=%s', + async (navigate) => { + const source = useAppStore + .getState() + .createUnifiedTab('wt-1', 'terminal', { id: 'source-terminal', entityId: 'source-terminal' }) + const other = useAppStore + .getState() + .createUnifiedTab('wt-1', 'agent-session', { entityId: 'D', activate: false }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [terminalRecord(source.id)] }, + terminalLayoutsByTabId: { + [source.id]: { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'source-pty' } + } + } + }) + await mainLoadsStoreLayout() + useAppStore.getState().activateTab(source.id) + useAppStore.getState().setActiveTabType('terminal', 'wt-1') + mocks.source.current = { + agent: 'codex', + executionHostId: 'local', + workspaceId: 'wt-1', + tabId: source.id, + paneKey: `${source.id}:leaf`, + ptyId: 'source-pty', + providerSessionId: 'saved-provider' + } + const admission = Promise.withResolvers<{ supported: true }>() + mocks.support.mockReturnValue(admission.promise) + mocks.create.mockImplementation( + async ( + params: StructuredAgentSessionCreateParams + ): Promise> => { + const sessionId = params.envelope.sessionId + const cursor = { epoch: 'fixture-epoch', sequence: 0 } + return { + ok: true, + replayed: false, + fence: 1, + cursor, + value: { + sessionId, + fence: 1, + unconfirmedClientMessageIds: [], + page: { + sessionId, + epoch: cursor.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + hasOlder: false, + hasNewer: false + } + } + } + } + ) + setLocalRuntimeCapabilitiesForTests([]) + useAppStore.setState((state) => ({ + settings: { + ...getDefaultSettings('/home/test'), + ...state.settings, + experimentalNativeChat: true + } + })) + const running = runConversationSwitch({ + direction: 'chat', + ...mocks.source.current, + source: mocks.source.current + }) + await vi.waitFor(() => expect(mocks.support).toHaveBeenCalledOnce()) + expect(mocks.stopped).toHaveBeenCalledExactlyOnceWith({ + worktree: 'id:wt-1', + expectedPtyIds: ['source-pty'], + targetOnly: true, + keepHistory: true + }) + expect(mocks.create).not.toHaveBeenCalled() + await main.settle() + if (navigate) { + noteUserWorkspaceSwitch('wt-1') + useAppStore.getState().activateTab(other.id) + } + admission.resolve({ supported: true }) + await running + await main.settle() + const chats = useAppStore + .getState() + .unifiedTabsByWorktree['wt-1'].filter( + (tab) => tab.contentType === 'agent-session' && tab.entityId !== 'D' + ) + expect(mocks.create).toHaveBeenCalledOnce() + expect(chats).toHaveLength(1) + expect(useAppStore.getState().getActiveTab('wt-1')?.id).toBe(navigate ? other.id : chats[0].id) + expect(mocks.create.mock.calls[0][0].resumeFrom.providerSessionId).toBe('saved-provider') + expect(mocks.create.mock.calls[0][0].envelope.sessionId).toBe(chats[0].entityId) + } +) diff --git a/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.ts index 8cf8075cdb6f..7539d4ff0cad 100644 --- a/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.ts @@ -57,7 +57,9 @@ export function structuredLaunchOwner( /** Only a launch that takes the user to its chat may ask host publication to select it. */ function launchHooks(args: ProvisionalLaunchArgs): StructuredAgentLaunchHooks { - return args.activate === false ? { ...args.hooks, activate: false } : args.hooks + return args.activate === false || args.canActivate + ? { ...args.hooks, activate: false } + : args.hooks } export type ProvisionalLaunchArgs = { @@ -68,6 +70,8 @@ export type ProvisionalLaunchArgs = { targetGroupId?: string afterTabId?: string activate?: boolean + viewerSelection?: string + canActivate?: () => boolean /** Identity-only resumes publish on commit; unknown outcomes keep their recovery tab. */ deferPublication?: true /** Allow workspace reveal before host acquisition, when the launch has no session ID or owned chat tab yet. */ @@ -102,7 +106,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( ): ReturnType => { if (launch?.tab) { tabOpened = true - if (args.activate !== false) { + if (args.activate !== false && args.canActivate?.() !== false) { focusIntent?.request(launch.tab.id) } } else if (!launch) { @@ -204,7 +208,9 @@ function beginLocalProvisionalLaunch( agent: args.plan.agent, ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), - ...(args.activate !== undefined ? { activate: args.activate } : {}) + ...(args.activate !== undefined ? { activate: args.activate } : {}), + viewerSelection: args.viewerSelection, + canActivate: args.canActivate }) return { ...handle, tab } } catch (error) { @@ -224,7 +230,32 @@ function beginDeferredPublication( const releaseHold = holdEmptyWorkspaceDefaultSurfaceForLaunch(worktreeId) let handle: StructuredAgentLaunchHandle | null try { - handle = args.plan.begin(launchHooks(args), args.target) + const hooks = launchHooks(args) + handle = args.plan.begin( + args.viewerSelection + ? { + ...hooks, + onStructuredReady: (sessionId) => { + hooks.onStructuredReady?.(sessionId) + if (!handle) { + return + } + openStructuredAgentSessionProvisionalTab({ + worktreeId, + executionHostId: handle.executionHostId, + sessionId, + agent: args.plan.agent, + targetGroupId: args.targetGroupId, + afterTabId: args.afterTabId, + activate: args.activate, + viewerSelection: args.viewerSelection, + canActivate: args.canActivate + }) + } + } + : hooks, + args.target + ) } catch (error) { releaseHold() throw error diff --git a/src/renderer/src/lib/agent-session/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/agent-session/structured-agent-session-tab-activation.ts index bc560cad8ca8..8974e9b22f43 100644 --- a/src/renderer/src/lib/agent-session/structured-agent-session-tab-activation.ts +++ b/src/renderer/src/lib/agent-session/structured-agent-session-tab-activation.ts @@ -1,3 +1,5 @@ +import { isSessionTabSelectionRequest } from '../../../../shared/session-tab-placement' +import { selectSessionTabForViewer } from '@/runtime/web-session-tabs-sync/store-patch' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '../worktree-runtime-owner' import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { useAppStore } from '@/store/index' @@ -13,25 +15,36 @@ import type { Tab } from '../../../../shared/tab-types' export function findStructuredAgentSessionTab( unifiedTabsByWorktree: Readonly>, - args: { workspaceId: string; sessionId: string; target?: RuntimeClientTarget } + args: { sessionId: string } & ( + | { workspaceId: string; target?: RuntimeClientTarget } + | { workspaceId?: undefined; target: RuntimeClientTarget } + ) ): Tab | null { - return ( - unifiedTabsByWorktree[args.workspaceId]?.find( + const workspaceIds = + args.workspaceId === undefined ? Object.keys(unifiedTabsByWorktree) : [args.workspaceId] + for (const workspaceId of workspaceIds) { + const tab = unifiedTabsByWorktree[workspaceId]?.find( (candidate) => - candidate.worktreeId === args.workspaceId && + candidate.worktreeId === workspaceId && candidate.contentType === 'agent-session' && candidate.entityId === args.sessionId && (!args.target || structuredAgentSessionOwnerForTab(useAppStore.getState(), candidate) === executionHostIdForStructuredTarget(args.target)) - ) ?? null - ) + ) + if (tab) { + return tab + } + } + return null } export function activateStructuredAgentSessionTab(args: { worktreeId: string tabId: string target?: RuntimeClientTarget + callerOnly?: boolean + viewerSelection?: string }): boolean { const state = useAppStore.getState() const tab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( @@ -45,11 +58,26 @@ export function activateStructuredAgentSessionTab(args: { if (!tab) { return false } - state.focusGroup(args.worktreeId, tab.groupId) - state.activateTab(tab.id, { worktreeId: args.worktreeId }) - state.setActiveTabType('agent-session', args.worktreeId) + if (args.viewerSelection && isSessionTabSelectionRequest(args.viewerSelection)) { + if ( + !selectSessionTabForViewer( + args.worktreeId, + tab.id, + args.viewerSelection, + args.target?.kind === 'environment' ? args.target.environmentId : undefined + ) + ) { + return false + } + } else { + state.focusGroup(args.worktreeId, tab.groupId) + state.activateTab(tab.id, { worktreeId: args.worktreeId }) + state.setActiveTabType('agent-session', args.worktreeId) + } requestNativeChatComposerFocus(tab.id) - activateStructuredAgentSessionTabOnOwner(args.worktreeId, tab.entityId, args.target) + activateStructuredAgentSessionTabOnOwner(args.worktreeId, tab.entityId, args.target, { + callerOnly: args.callerOnly + }) return true } @@ -77,6 +105,8 @@ export function activateStructuredAgentSessionById(args: { worktreeId: string sessionId: string target?: RuntimeClientTarget + callerOnly?: boolean + viewerSelection?: string }): boolean { const tab = findStructuredAgentSessionTab(useAppStore.getState().unifiedTabsByWorktree, { workspaceId: args.worktreeId, @@ -87,7 +117,9 @@ export function activateStructuredAgentSessionById(args: { ? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id, - target: args.target + target: args.target, + callerOnly: args.callerOnly, + viewerSelection: args.viewerSelection }) : false } diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index e2ed995b2d98..15e3db82bf6f 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -55,7 +55,8 @@ function registerCandidate( refresh: boolean tabId?: string worktreeId?: string - } + }, + allPanes = false ): void { if (!isAgent(args.agent) || !args.providerSession?.id) { return @@ -74,7 +75,7 @@ function registerCandidate( return } const priority = args.priority + (activePaneKey(state, tabId) === args.paneKey ? 100 : 0) - const key = tabId + const key = allPanes ? args.paneKey : tabId if ((candidates.get(key)?.priority ?? -1) >= priority) { return } @@ -142,6 +143,8 @@ export function collectAgentPaneConversations( options: { /** Skip retained and sleeping records: those remember a conversation after its agent exited. */ runningAgentsOnly?: boolean + /** History addresses a conversation in any pane, including an inactive split. */ + allPanes?: boolean includeLastReported?: boolean pane?: { worktreeId: string; paneKey: string } tab?: { worktreeId: string; tabId: string } @@ -189,35 +192,63 @@ export function collectAgentPaneConversations( for (const identity of records(state.agentConversationsByPaneKey)) { const candidate = lastReportedCandidate(state, identity, tabsById) if (candidate) { - registerCandidate(state, tabsById, candidates, isAgent, candidate) + registerCandidate( + state, + tabsById, + candidates, + isAgent, + candidate, + options.allPanes || !!options.pane + ) } } } for (const entry of retained) { - registerCandidate(state, tabsById, candidates, isAgent, { - agent: entry.agentType, - paneKey: entry.entry.paneKey, - priority: 10, - providerSession: entry.entry.providerSession, - refresh: false, - tabId: entry.entry.tabId, - worktreeId: entry.worktreeId - }) + registerCandidate( + state, + tabsById, + candidates, + isAgent, + { + agent: entry.agentType, + paneKey: entry.entry.paneKey, + priority: 10, + providerSession: entry.entry.providerSession, + refresh: false, + tabId: entry.entry.tabId, + worktreeId: entry.worktreeId + }, + options.allPanes || !!options.pane + ) } for (const record of sleeping) { - registerCandidate(state, tabsById, candidates, isAgent, { - agent: record.agent, - paneKey: record.paneKey, - priority: 20, - providerSession: record.providerSession, - refresh: false, - tabId: record.tabId, - worktreeId: record.worktreeId - }) + registerCandidate( + state, + tabsById, + candidates, + isAgent, + { + agent: record.agent, + paneKey: record.paneKey, + priority: 20, + providerSession: record.providerSession, + refresh: false, + tabId: record.tabId, + worktreeId: record.worktreeId + }, + options.allPanes || !!options.pane + ) } for (const entry of records(state.agentStatusByPaneKey)) { - registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(entry)) + registerCandidate( + state, + tabsById, + candidates, + isAgent, + liveCandidate(entry), + options.allPanes || !!options.pane + ) } return [...candidates.values()] @@ -233,6 +264,12 @@ export function resolvePaneAgentConversation( return collectAgentPaneConversations(state, isAgent, { pane, runningAgentsOnly: true })[0] ?? null } +/** The PTY a pane is bound to now. */ +export function panePtyId(state: AppState, paneKey: string): string | undefined { + const pane = parsePaneKey(paneKey) + return pane ? state.terminalLayoutsByTabId[pane.tabId]?.ptyIdsByLeafId?.[pane.leafId] : undefined +} + /** One pane's conversation survives occupancy cleanup after its agent exits. */ export function resolvePaneLastAgentConversation( state: AppState, diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index cc5854df21b7..2100456b6737 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -1,5 +1,6 @@ import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' +import type { PtyExitOptions } from '@/components/terminal-pane/pty-exit-delivery' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/remote-runtime-terminal-multiplexer' import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime/remote-runtime-pty-id' @@ -18,7 +19,7 @@ export async function observeExistingAutomationSession(args: { runId: string onData: (chunk: string) => void onAgentStatus: (payload: ParsedAgentStatusPayload) => void - onExit: (code: number) => void + onExit: (code: number, options?: PtyExitOptions) => void }): Promise<() => void> { const { ptyId, paneKey, runId, onData, onExit } = args // Main owns local/SSH OSC 9999 writes unless the kill switch restores legacy behavior; runtime bytes still need renderer writes. Completion callbacks always fire here. @@ -97,7 +98,13 @@ export async function observeExistingAutomationSession(args: { } const unsubscribeData = subscribeToPtyData(ptyId, handleData) - const unsubscribeExit = subscribeToPtyExit(ptyId, onExit) + const unsubscribeExit = subscribeToPtyExit(ptyId, (code, context) => { + if (context.exitOptions?.preserveRendererBinding) { + onExit(code, context.exitOptions) + } else { + onExit(code) + } + }) return () => { unsubscribeData() unsubscribeExit() diff --git a/src/renderer/src/lib/automation-terminal-ownership.test.ts b/src/renderer/src/lib/automation-terminal-ownership.test.ts index 836582286904..7e18abb36d21 100644 --- a/src/renderer/src/lib/automation-terminal-ownership.test.ts +++ b/src/renderer/src/lib/automation-terminal-ownership.test.ts @@ -262,12 +262,14 @@ describe('automation terminal ownership', () => { }) it('release consumes ownership without closing the terminal', async () => { - const { closeTab, store } = createStore() + const { closeTab, shutdownCompletedAgentPaneForHibernation, store } = createStore() const ownership = own(store) ownership.release() expect(await ownership.finalize({ preservePane: false })).toBeNull() + expect(await ownership.finalize({ preservePane: true })).toBeNull() expect(closeTab).not.toHaveBeenCalled() + expect(shutdownCompletedAgentPaneForHibernation).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/cli-conversation-exit-guidance.ts b/src/renderer/src/lib/cli-conversation-exit-guidance.ts index fd50d3cb552a..71d7e68182dc 100644 --- a/src/renderer/src/lib/cli-conversation-exit-guidance.ts +++ b/src/renderer/src/lib/cli-conversation-exit-guidance.ts @@ -2,7 +2,6 @@ import { translate } from '@/i18n/i18n' import { agentLabel } from '../../../shared/ai-vault-session-filters' import type { CliConversationRowAgent } from '../../../shared/structured-agent-cli-conversation-adoption' -/** The header action disappears once the agent exits, so that surface points to Session History. */ export function cliConversationExitGuidance( agent: CliConversationRowAgent, fromTerminal = false @@ -11,12 +10,12 @@ export function cliConversationExitGuidance( return fromTerminal ? translate( 'components.native-chat.resumeInNewChat.exitUnconfirmedFromTerminal', - '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', + 'Orca could not confirm {{agent}} stopped in this terminal. Quit {{agent}} here, then try this action again.', values ) : translate( 'components.native-chat.resumeInNewChat.exitUnconfirmed', - '{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal, then open this conversation from Session History.', + 'Orca could not confirm {{agent}} stopped. Quit {{agent}} in its terminal, then try this action again.', values ) } diff --git a/src/renderer/src/lib/conversation-surface-switch.test.tsx b/src/renderer/src/lib/conversation-surface-switch.test.tsx new file mode 100644 index 000000000000..e4d55eb94a87 --- /dev/null +++ b/src/renderer/src/lib/conversation-surface-switch.test.tsx @@ -0,0 +1,642 @@ +// @vitest-environment happy-dom +import { cleanup } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type * as SessionOwner from '@/runtime/structured-agent-session-owner' +import { useAppStore } from '@/store' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../shared/structured-agent-session-reducer' +import { runConversationSwitch, runNativeChatCliSwitch } from './conversation-surface-switch' +import { pendingNativeChatCliSwitches } from './pending-conversation-surface-switches' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createWebSessionTabsSnapshotPatch } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { applyWebSessionTabsStorePatch } from '@/runtime/web-session-tabs-sync/store-patch' +import { activateTabNumberShortcut } from './tab-number-shortcuts' +import { + beginViewerSelectionRequest, + disposeViewerSelection, + viewerSelectionOutcome +} from './viewer-selection-request' +import { presentConversationSwitchResult } from './conversation-switch-result-selection' +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import type { Tab } from '../../../shared/tab-types' +const mocks = vi.hoisted(() => { + function makeTarget(): { current: RuntimeClientTarget } { + return { current: { kind: 'local' } } + } + return { + target: makeTarget(), + support: vi.fn(), + rpc: vi.fn(), + capture: vi.fn(), + prepare: vi.fn(), + pairedRefresh: vi.fn(), + call: vi.fn(), + legacy: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + error: vi.fn(), + info: vi.fn(), + reader: vi.fn(), + listeners: new Set<() => void>(), + subscribe: (listener: () => void) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +}) +vi.mock('sonner', () => ({ toast: { error: mocks.error, info: mocks.info } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: mocks.support, + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/structured-agent-session-owner', async (original) => ({ + ...(await original()), + structuredAgentSessionTargetForHost: () => mocks.target.current +})) +vi.mock('@/runtime/runtime-rpc-client', async (original) => ({ + ...(await original()), + callRuntimeRpc: mocks.rpc +})) +vi.mock('@/components/tab-bar/cli-chat-resume-terminal-capture', () => ({ + captureSourceTerminal: mocks.capture +})) +vi.mock('./ai-vault-session-resume-preparation', () => ({ + prepareAiVaultSessionForResume: mocks.prepare +})) +vi.mock('@/runtime/web-runtime-session-snapshot', () => ({ + refreshWebRuntimeSessionTabsSnapshot: mocks.pairedRefresh +})) +vi.mock('./in-place-switch-support', () => ({ + useInPlaceSwitchCapability: () => true +})) +vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ + getStructuredAgentSessionStatusFeed: () => ({ + subscribe: mocks.subscribe, + getSnapshot: () => new Map() + }) +})) +vi.mock('@/components/native-chat/structured-agent-session-read-owner-registry', () => ({ + findStructuredAgentSessionReadOwner: mocks.reader +})) +vi.mock('@/components/tab-bar/cli-chat-resume-run', () => ({ + runLegacyCliChatResume: mocks.legacy +})) +vi.mock('@/components/tab-bar/native-chat-cli-switch-run', () => ({ + runLegacyNativeChatCliSwitch: mocks.legacy +})) +vi.mock('@/components/tab-bar/cli-chat-resume-feedback', () => ({ + switchIntoOwnerLegacy: mocks.legacy +})) +vi.mock('@/runtime/local-structured-session-tabs-sync/inventory-refresh', () => ({ + refreshLocalStructuredSessionWorktreeTabs: mocks.refresh +})) +vi.mock('@/components/right-sidebar/ai-vault-session-resume-in-chat-launch', () => ({ + resumeAiVaultSessionInNewChat: mocks.legacy, + adoptingAgentOnHost: async (agent: string) => agent +})) + +const subject = { + direction: 'terminal' as const, + executionHostId: 'local', + workspaceId: 'workspace', + sessionId: 'codex_chat', + agent: 'codex' as const, + chatTabId: 'source' +} +beforeEach(() => { + vi.clearAllMocks() + createTabsSliceMockApi() + mocks.target.current = { kind: 'local' } + mocks.rpc.mockResolvedValue({}) + mocks.pairedRefresh.mockResolvedValue(undefined) + mocks.capture.mockResolvedValue({ + handle: 'terminal-source', + incarnationId: 'incarnation', + presentation: {} + }) + mocks.reader.mockReturnValue(undefined) + mocks.support.mockResolvedValue(true) + mocks.call.mockResolvedValue({ status: 'completed', sessionId: subject.sessionId }) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, experimentalNativeChat: true } + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + pendingNativeChatCliSwitches.clear() +}) +it('reserves pending before negotiation, sends one execution request and leaves all launching to the host', async () => { + const negotiation = Promise.withResolvers() + mocks.support.mockReturnValue(negotiation.promise) + const first = runConversationSwitch(subject) + expect(await runConversationSwitch(subject)).toBe(false) + negotiation.resolve(true) + expect(await first).toBe(true) + expect(mocks.support).toHaveBeenCalledOnce() + expect(mocks.call).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.switchToTerminal', + expect.objectContaining({ + sessionId: subject.sessionId, + execution: expect.objectContaining({ sourceTabId: 'source' }) + }), + expect.objectContaining({ signal: expect.any(AbortSignal), beforeSend: expect.any(Function) }) + ) + expect(mocks.legacy).not.toHaveBeenCalled() + expect(pendingNativeChatCliSwitches.size).toBe(0) +}) +it('formats real refusal categories once, with agent and next step, never raw errors or platform-wrong job control', async () => { + mocks.call.mockRejectedValue(new Error('secret raw transport/provider failure')) + await runConversationSwitch({ ...subject, direction: 'chat' }) + expect(mocks.error).toHaveBeenCalledExactlyOnceWith( + 'Codex could not start in chat. Check its setup and try again.' + ) +}) + +it.each([false, true])( + 'asks once for a live reply; consent=%s has only the authorized effects', + async (yes) => { + mocks.reader.mockReturnValue({ + getSnapshot: () => ({ + current: true, + state: { + ...EMPTY_STRUCTURED_AGENT_SESSION, + working: true, + latestTurn: { itemId: 'turn', observedAt: 1, turn: { turnId: 'turn', state: 'running' } } + } + }) + }) + const answer = Promise.withResolvers() + const confirm = vi.fn(() => answer.promise) + const first = runConversationSwitch({ ...subject, confirm }) + expect(await runConversationSwitch({ ...subject, confirm })).toBe(false) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()) + expect(confirm).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + title: 'Codex is replying. Stop it and switch?', + compact: true + }) + ) + expect(mocks.support).toHaveBeenCalledOnce() + expect(mocks.call).not.toHaveBeenCalled() + answer.resolve(yes) + expect(await first).toBe(yes) + expect(mocks.call).toHaveBeenCalledTimes(yes ? 1 : 0) + expect(mocks.refresh).toHaveBeenCalledTimes(yes ? 1 : 0) + } +) + +it.each([10, 100])('bounds real result discovery and selection at %i workspaces', async (count) => { + const environmentId = 'scale-owner' + const target = { kind: 'environment', environmentId } as const + const sourceWorkspace = 'folder:scale-0' + const destinationWorkspace = `folder:scale-${count - 1}` + const sessionId = 'scale-native-A' + disposeViewerSelection() + useAppStore.setState({ activeWorktreeId: sourceWorkspace, activeView: 'terminal' }) + for (let index = 0; index < count; index++) { + const sibling = terminal(index === 0 ? 'B' : `sibling-${index}`) + const id = index === 1 || index === count - 1 ? sessionId : `unrelated-${index}` + const second = + index === 0 + ? terminal('C') + : { + type: 'agent-session' as const, + id: `agent-session:${id}`, + sessionId: id, + agent: 'codex' as const, + title: id, + isActive: false + } + applySwitchSnapshot( + { + worktree: `folder:scale-${index}`, + publicationEpoch: 'result-scale', + snapshotVersion: 1, + activeGroupId: 'G', + activeTabId: sibling.id, + activeTabType: 'terminal', + tabs: [sibling, second], + tabGroups: [ + { + id: 'G', + activeTabId: sibling.parentTabId, + tabOrder: [ + sibling.parentTabId, + second.type === 'terminal' ? second.parentTabId : second.id + ] + } + ] + }, + index === 1 ? 'other-owner' : environmentId + ) + } + const before = useAppStore.getState() + const destinationRows = before.unifiedTabsByWorktree[destinationWorkspace] + const destination = destinationRows.find((tab) => tab.entityId === sessionId)! + const originalOrder = before.groupsByWorktree[destinationWorkspace][0].tabOrder + const sourceRows = before.unifiedTabsByWorktree[sourceWorkspace] + const sourceTerminals = before.tabsByWorktree[sourceWorkspace] + const sourceLayouts = sourceRows.map((tab) => before.terminalLayoutsByTabId[tab.entityId]) + expect(destinationRows).toHaveLength(2) + expect( + Object.values(before.unifiedTabsByWorktree).reduce((sum, rows) => sum + rows.length, 0) + ).toBe(2 * count) + expect(destination.executionHostId).toBe('runtime:scale-owner') + expect( + before.unifiedTabsByWorktree['folder:scale-1'].find((tab) => tab.entityId === sessionId) + ?.executionHostId + ).toBe('runtime:other-owner') + + const [activation, owners, storePatch, viewer] = await Promise.all([ + import('./agent-session/structured-agent-session-tab-activation'), + import('@/runtime/structured-agent-session-owner'), + import('@/runtime/web-session-tabs-sync/store-patch'), + import('./viewer-selection-request') + ]) + const discovery = { calls: 0, keyPasses: 0, keys: 0, workspaces: 0, tabs: 0 } + const candidateVisits = new Map() + const realFind = activation.findStructuredAgentSessionTab + const lookup = vi + .spyOn(activation, 'findStructuredAgentSessionTab') + .mockImplementation((tabs, args) => { + if (args.workspaceId !== undefined) { + return realFind(tabs, args) + } + discovery.calls++ + return realFind( + new Proxy(tabs, { + ownKeys(actual) { + const keys = Reflect.ownKeys(actual) + discovery.keyPasses++ + discovery.keys += keys.length + return keys + }, + get(actual, property, receiver) { + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. + const rows: readonly Tab[] | undefined = Reflect.get(actual, property, receiver) + if (typeof property !== 'string' || !Object.hasOwn(actual, property) || !rows) { + return rows + } + discovery.workspaces++ + return new Proxy(rows, { + get(actualRows, key, arrayReceiver) { + if (typeof key === 'string' && /^(0|[1-9]\d*)$/.test(key)) { + discovery.tabs++ + const row = `${property}:${key}` + candidateVisits.set(row, (candidateVisits.get(row) ?? 0) + 1) + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. + return Reflect.get(actualRows, key, arrayReceiver) + } + }) + } + }), + args + ) + }) + const qualification = vi.spyOn(owners, 'structuredAgentSessionOwnerForTab') + const selection = vi.spyOn(storePatch, 'selectSessionTabForViewer') + const commit = vi.spyOn(viewer, 'commitViewerSelection') + const deadline = agentSessionSwitchDeadline(Date.now) + const request = beginViewerSelectionRequest(`result-scale-${count}`, deadline) + request.accept() + mocks.pairedRefresh.mockClear() + mocks.rpc.mockClear() + try { + await presentConversationSwitchResult( + { + ...subject, + direction: 'chat', + workspaceId: sourceWorkspace, + executionHostId: 'runtime:scale-owner' + }, + target, + { status: 'completed', sessionId }, + request.token + ) + expect(discovery).toEqual({ + calls: 1, + keyPasses: 1, + keys: count, + workspaces: count, + tabs: 2 * count + }) + expect(qualification).toHaveBeenCalledTimes(4) + expect(candidateVisits.size).toBe(2 * count) + expect(new Set(candidateVisits.values())).toEqual(new Set([1])) + expect(qualification.mock.calls.map(([, tab]) => tab.executionHostId)).toEqual([ + 'runtime:other-owner', + 'runtime:scale-owner', + 'runtime:scale-owner', + 'runtime:scale-owner' + ]) + console.info( + `[result-selection-work] W=${count} T=${2 * count} discoveryKeys=${discovery.keys} workspaceVisits=${discovery.workspaces} tabVisits=${discovery.tabs} ownerChecks=${qualification.mock.calls.length}` + ) + expect(selection).toHaveBeenCalledExactlyOnceWith( + destinationWorkspace, + destination.id, + request.token, + environmentId + ) + expect(commit).toHaveBeenCalledOnce() + expect(viewerSelectionOutcome(request.token)).toBe('fulfilled') + expect(mocks.pairedRefresh).toHaveBeenCalledExactlyOnceWith( + environmentId, + destinationWorkspace, + { + acceptCurrentSnapshot: true, + afterCurrentInFlight: true + } + ) + expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith(target, 'session.tabs.activate', { + worktree: `id:${destinationWorkspace}`, + tabId: `agent-session:${sessionId}`, + notifyClients: false, + navigation: 'caller' + }) + const after = useAppStore.getState() + expect(after.activeWorktreeId).toBe(destinationWorkspace) + expect( + after.groupsByWorktree[destinationWorkspace].find( + (group) => group.id === after.activeGroupIdByWorktree[destinationWorkspace] + )?.activeTabId + ).toBe(destination.id) + expect(after.activeTabType).toBe('agent-session') + expect(after.unifiedTabsByWorktree[destinationWorkspace]).toHaveLength(2) + expect(after.groupsByWorktree[destinationWorkspace][0].tabOrder).toEqual(originalOrder) + expect(after.unifiedTabsByWorktree[sourceWorkspace]).toBe(sourceRows) + expect(after.tabsByWorktree[sourceWorkspace]).toBe(sourceTerminals) + expect(sourceRows.map((tab) => after.terminalLayoutsByTabId[tab.entityId])).toEqual( + sourceLayouts + ) + } finally { + lookup.mockRestore() + qualification.mockRestore() + selection.mockRestore() + commit.mockRestore() + deadline.dispose() + disposeViewerSelection() + useAppStore.setState(useAppStore.getInitialState(), true) + } +}) + +it.each(['codex'] as const)( + 'dispatches idle %s once through the common host command, preserving configured commands', + async (agent) => { + const confirm = vi.fn() + useAppStore.setState({ + settings: { + ...useAppStore.getState().settings!, + agentCmdOverrides: { [agent]: 'my-alias --option' } + } + }) + expect(await runConversationSwitch({ ...subject, agent, confirm })).toBe(true) + expect(confirm).not.toHaveBeenCalled() + expect(mocks.call).toHaveBeenCalledExactlyOnceWith( + { kind: 'local' }, + 'agentSession.switchToTerminal', + expect.objectContaining({ + sessionId: subject.sessionId, + execution: expect.objectContaining({ commandOverride: 'my-alias --option' }) + }), + expect.any(Object) + ) + } +) + +it('does not offer chat switching while Chat UI is off', async () => { + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, experimentalNativeChat: false } + }) + expect(await runConversationSwitch({ ...subject, direction: 'chat' })).toBe(false) + expect(mocks.support).not.toHaveBeenCalled() + expect(mocks.call).not.toHaveBeenCalled() +}) + +it('Continue with Chat UI off uses the same host switch identity, never copied/fork startup', async () => { + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, experimentalNativeChat: false } + }) + const legacyContinue = vi.fn(async () => {}) + await runNativeChatCliSwitch({ ...subject, legacyContinue }) + expect(mocks.call).toHaveBeenCalledExactlyOnceWith( + { kind: 'local' }, + 'agentSession.switchToTerminal', + expect.objectContaining({ + sessionId: subject.sessionId, + execution: expect.objectContaining({ + sourceTabId: subject.chatTabId, + viewerSelection: expect.stringMatching(/^selection-v1:/) + }) + }), + expect.any(Object) + ) + expect(legacyContinue).not.toHaveBeenCalled() + expect(mocks.legacy).not.toHaveBeenCalled() +}) + +it('old-host Continue while replying reports its copy outcome without promising Stop', async () => { + mocks.support.mockResolvedValue(false) + mocks.reader.mockReturnValue({ + getSnapshot: () => ({ + current: true, + state: { + ...EMPTY_STRUCTURED_AGENT_SESSION, + working: true, + latestTurn: { itemId: 'turn', observedAt: 1, turn: { turnId: 'turn', state: 'running' } } + } + }) + }) + const confirm = vi.fn() + const legacyContinue = vi.fn(async () => {}) + await runNativeChatCliSwitch({ ...subject, confirm, legacyContinue }) + expect(mocks.support).toHaveBeenCalledOnce() + expect(confirm).not.toHaveBeenCalled() + expect(mocks.call).not.toHaveBeenCalled() + expect(legacyContinue).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/^selection-v1:/)) +}) + +const terminal = (id: string) => ({ + type: 'terminal' as const, + id: `${id}::11111111-1111-4111-8111-111111111111`, + parentTabId: id, + leafId: '11111111-1111-4111-8111-111111111111', + title: id, + terminal: `term_${id}`, + isActive: false, + status: 'ready' as const +}) +const applySwitchSnapshot = (frame: RuntimeMobileSessionTabsResult, owner: string) => + applyWebSessionTabsStorePatch(createWebSessionTabsSnapshotPatch(frame, owner), { + frames: [] + })() + +it.each([ + { workspace: 'folder:existing-chat', navigate: false }, + { workspace: 'folder:existing-chat', navigate: true }, + { workspace: 'folder:terminal-source', navigate: false } +])( + 'selects the completed session in its actual $workspace without relocating it (navigation=$navigate)', + async ({ workspace, navigate }) => { + const environmentId = 'switch-owner' + const target = { kind: 'environment', environmentId } as const + const sourceWorkspace = 'folder:terminal-source' + const sessionId = 'existing-native-A' + const sourceFrame: RuntimeMobileSessionTabsResult = { + worktree: sourceWorkspace, + publicationEpoch: 'result-selection', + snapshotVersion: 1, + activeGroupId: 'source-group', + activeTabId: terminal('B').id, + activeTabType: 'terminal', + tabs: [terminal('B'), terminal('C')], + tabGroups: [{ id: 'source-group', activeTabId: 'B', tabOrder: ['B', 'C'] }] + } + const destinationFrame: RuntimeMobileSessionTabsResult = { + ...sourceFrame, + worktree: workspace, + activeGroupId: workspace === sourceWorkspace ? 'source-group' : 'chat-group', + activeTabId: + workspace === sourceWorkspace ? sourceFrame.activeTabId : `agent-session:${sessionId}`, + activeTabType: workspace === sourceWorkspace ? 'terminal' : 'agent-session', + tabs: [ + ...(workspace === sourceWorkspace ? sourceFrame.tabs : []), + { + type: 'agent-session', + id: 'agent-session:existing-native-A', + sessionId, + agent: 'codex', + title: 'Existing chat A', + isActive: false + } + ], + tabGroups: [ + ...(workspace === sourceWorkspace ? (sourceFrame.tabGroups ?? []) : []), + { + id: 'chat-group', + activeTabId: `agent-session:${sessionId}`, + tabOrder: [`agent-session:${sessionId}`] + } + ] + } + const apply = (frame: RuntimeMobileSessionTabsResult, owner = environmentId) => + applySwitchSnapshot(frame, owner) + disposeViewerSelection() + mocks.target.current = target + useAppStore.setState({ activeWorktreeId: sourceWorkspace, activeView: 'terminal' }) + apply(sourceFrame) + // The same session string on a different host is not this command's destination. + apply( + { + ...destinationFrame, + worktree: 'folder:foreign-chat', + tabs: destinationFrame.tabs.filter((tab) => tab.type === 'agent-session'), + tabGroups: destinationFrame.tabGroups?.filter((group) => group.id === 'chat-group'), + activeGroupId: 'chat-group', + activeTabId: `agent-session:${sessionId}`, + activeTabType: 'agent-session' + }, + 'other-owner' + ) + apply(destinationFrame) + const before = useAppStore.getState() + expect(before.activeWorktreeId).toBe(sourceWorkspace) + const sourceTabs = before.tabsByWorktree[sourceWorkspace] + const sourceRows = before.unifiedTabsByWorktree[sourceWorkspace].filter( + (tab) => tab.contentType === 'terminal' + ) + const destination = before.unifiedTabsByWorktree[workspace].find( + (tab) => tab.entityId === sessionId + )! + const chatGroup = before.groupsByWorktree[workspace].find( + (group) => group.id === destination.groupId + )! + const refresh = Promise.withResolvers() + mocks.pairedRefresh.mockImplementation(() => refresh.promise) + const result = { status: 'completed', sessionId } as const + mocks.call.mockResolvedValue(result) + const args = { + ...subject, + direction: 'chat' as const, + workspaceId: sourceWorkspace, + executionHostId: 'runtime:switch-owner' + } + try { + const switching = runConversationSwitch(args) + await vi.waitFor(() => expect(mocks.pairedRefresh).toHaveBeenCalledOnce()) + expect(mocks.pairedRefresh).toHaveBeenCalledWith(environmentId, workspace, { + acceptCurrentSnapshot: true, + afterCurrentInFlight: true + }) + const token = mocks.call.mock.calls[0][2].execution.viewerSelection + if (navigate) { + expect(activateTabNumberShortcut(1)).toBe(true) + } + const beforeResult = useAppStore.getState() + const selectedBeforeResult = beforeResult.activeTabIdByWorktree[sourceWorkspace] + const selectedGroupBeforeResult = beforeResult.groupsByWorktree[sourceWorkspace].find( + (group) => group.id === beforeResult.activeGroupIdByWorktree[sourceWorkspace] + ) + refresh.resolve() + expect(await switching).toBe(true) + const after = useAppStore.getState() + const selectedWorkspace = navigate ? sourceWorkspace : workspace + const activeGroupId = after.activeGroupIdByWorktree[selectedWorkspace] + const selectedGroup = after.groupsByWorktree[selectedWorkspace].find( + (group) => group.id === activeGroupId + ) + expect(after.activeWorktreeId).toBe(selectedWorkspace) + expect(selectedGroup?.activeTabId).toBe( + navigate ? selectedGroupBeforeResult?.activeTabId : destination.id + ) + expect(after.activeTabType).toBe(navigate ? 'terminal' : 'agent-session') + expect(after.activeTabTypeByWorktree[selectedWorkspace]).toBe( + navigate ? 'terminal' : 'agent-session' + ) + expect(after.activeTabIdByWorktree[selectedWorkspace]).toBe( + navigate ? selectedBeforeResult : (before.activeTabIdByWorktree[workspace] ?? null) + ) + expect(viewerSelectionOutcome(token)).toBe(navigate ? 'superseded' : 'fulfilled') + expect(after.tabsByWorktree[sourceWorkspace]).toEqual(sourceTabs) + expect( + after.unifiedTabsByWorktree[sourceWorkspace] + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => ({ id: tab.id, entityId: tab.entityId, groupId: tab.groupId })) + ).toEqual( + sourceRows.map((tab) => ({ id: tab.id, entityId: tab.entityId, groupId: tab.groupId })) + ) + expect( + after.groupsByWorktree[workspace].find((group) => group.id === destination.groupId) + ?.tabOrder + ).toEqual(chatGroup.tabOrder) + expect( + after.unifiedTabsByWorktree[workspace].filter((tab) => tab.entityId === sessionId) + ).toHaveLength(1) + const accepted = after.activeTabIdByWorktree + await presentConversationSwitchResult(args, target, result, token) + const replayed = useAppStore.getState() + expect(replayed.activeTabIdByWorktree).toBe(accepted) + expect(replayed.activeWorktreeId).toBe(after.activeWorktreeId) + expect(replayed.activeGroupIdByWorktree[selectedWorkspace]).toBe(activeGroupId) + expect( + replayed.groupsByWorktree[selectedWorkspace].find((group) => group.id === activeGroupId) + ?.activeTabId + ).toBe(selectedGroup?.activeTabId) + expect(mocks.rpc).toHaveBeenCalledTimes(navigate ? 0 : 1) + if (!navigate) { + expect(mocks.rpc).toHaveBeenCalledWith(target, 'session.tabs.activate', { + worktree: `id:${workspace}`, + tabId: `agent-session:${sessionId}`, + notifyClients: false, + navigation: 'caller' + }) + } + } finally { + disposeViewerSelection() + } + } +) diff --git a/src/renderer/src/lib/conversation-surface-switch.ts b/src/renderer/src/lib/conversation-surface-switch.ts new file mode 100644 index 000000000000..a8ce45df2bd5 --- /dev/null +++ b/src/renderer/src/lib/conversation-surface-switch.ts @@ -0,0 +1,297 @@ +import { adoptingAgentOnHost } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch' +import { translate } from '@/i18n/i18n' +import { agentLabel } from '../../../shared/ai-vault-session-filters' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' +import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' +import { + inPlaceSwitchEnabled, + adoptsCliConversations +} from '../../../shared/structured-agent-cli-conversation-adoption' +import { toHostSessionPaneKey } from '../../../shared/terminal-surface-id' +import { + captureSwitchProjection, + releaseUnsentSwitchProjection +} from './conversation-switch-projection' +import { + beginSwitchPresentation, + reportSwitchPresentationFailure, + settleSwitchPresentation, + type SwitchPresentationVisit +} from './conversation-switch-presentation' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { + conversationSwitchFailureText, + conversationSwitchSourceKept +} from './conversation-switch-feedback' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + callStructuredAgentSession, + supportsInPlaceSwitch +} from '@/runtime/structured-agent-session-client' +import { createStructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' +import { createBrowserUuid } from './browser-uuid' +import { captureSourceTerminal } from '@/components/tab-bar/cli-chat-resume-terminal-capture' +import { + pendingNativeChatCliSwitches as pending, + conversationSwitchChanged +} from './pending-conversation-surface-switches' +import { + agentSessionSwitchDeadline, + AGENT_SESSION_SWITCH_CLIENT_MS, + type AgentSessionSwitchResult +} from '../../../shared/agent-session-switch-execution' +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { prepareAiVaultSessionForResume } from './ai-vault-session-resume-preparation' +import type { CliChatResumeSource } from '@/components/tab-bar/cli-chat-resume-source' +import { beginViewerSelectionRequest } from './viewer-selection-request' +import { presentConversationSwitchResult } from './conversation-switch-result-selection' +import { runLegacyConversationSwitch } from './conversation-switch-legacy' +import { + type ConversationSwitch, + keyFor, + pendingNow, + rederiveConversationSwitch, + conversationSwitchReplying, + withConversationSwitchPlatform +} from './conversation-switch-state' + +export { conversationSwitchFailureText } from './conversation-switch-feedback' + +export function runCliChatResume( + source: CliChatResumeSource, + confirm?: ConfirmationDialogContextValue +): Promise { + return runConversationSwitch({ direction: 'chat', ...source, source, confirm }).then( + () => undefined + ) +} +export function runNativeChatCliSwitch( + args: Omit & { onComplete?: () => void } +): Promise { + return runConversationSwitch({ ...args, direction: 'terminal' }) + .then(() => undefined) + .finally(args.onComplete) +} + +export async function runConversationSwitch(args: ConversationSwitch): Promise { + if ( + (args.direction === 'chat' && !isNativeChatEnabled(useAppStore.getState().settings)) || + !inPlaceSwitchEnabled(args.agent) || + pendingNow(args) + ) { + return false + } + args = withConversationSwitchPlatform(args) + const target = structuredAgentSessionTargetForHost(args.executionHostId) + const hostId = parseExecutionHostId(args.executionHostId)?.id + if (!target || !hostId) { + return false + } + const deadline = agentSessionSwitchDeadline(Date.now, AGENT_SESSION_SWITCH_CLIENT_MS) + const invocationId = createBrowserUuid() + const selection = beginViewerSelectionRequest(invocationId, deadline) + const viewerSelection = selection.token + const key = keyFor(args) + const feedback = + args.feedback ?? ((message: string, kind: 'error' | 'info') => toast[kind](message)) + pending.set(key, { + chatTabId: args.source?.tabId ?? args.chatTabId ?? null, + workspaceId: args.workspaceId, + executionHostId: args.executionHostId + }) + conversationSwitchChanged() + let consented = false + let sent = false + let presentation: SwitchPresentationVisit | undefined + try { + const supported = await deadline.wait(supportsInPlaceSwitch(target, args.agent, true)) + if (!supported && args.legacyContinue) { + selection.accept() + consented = true + await args.legacyContinue(viewerSelection) + return false + } + if (conversationSwitchReplying(args)) { + const confirmed = await args.confirm?.({ + title: translate( + 'components.tab.bar.switch.stopAndSwitchTitle', + '{{agent}} is replying. Stop it and switch?', + { agent: agentLabel(args.agent) } + ), + confirmLabel: translate('components.tab.bar.switch.stopAndSwitch', 'Stop and switch'), + cancelLabel: translate('common.cancel', 'Cancel'), + compact: true, + ignoresOpeningGesture: true + }) + if (!confirmed) { + return false + } + } + selection.accept() + consented = true + if (args.direction === 'terminal' && args.sessionId && args.chatTabId) { + captureSwitchProjection( + useAppStore.getState(), + args.workspaceId, + { + invocationId, + generation: invocationId, + sessionId: args.sessionId, + tabId: args.chatTabId, + phase: 'prepare' + }, + () => {} + ) + } + if (!supported) { + await runLegacyConversationSwitch(args, target, viewerSelection) + return false + } + let sessionId = args.sessionId + let execution: Record = { invocationId, viewerSelection } + if (args.direction === 'chat' && args.source) { + const captured = await deadline.wait(captureSourceTerminal(args.source)) + if (!captured) { + throw new Error('source unavailable') + } + const intent = createStructuredAgentSessionLaunchIntent( + args.workspaceId, + await deadline.wait( + adoptingAgentOnHost(args.source.agent, args.workspaceId, args.source.executionHostId) + ), + args.source.executionHostId, + { + providerSessionId: args.source.providerSessionId, + cliSource: { + workspaceId: args.workspaceId, + paneKey: toHostSessionPaneKey(args.source.paneKey) + } + }, + undefined, + false + ) + sessionId = intent.sessionId + const group = useAppStore + .getState() + .groupsByWorktree[args.workspaceId]?.find((row) => + row.tabOrder.includes(args.source!.tabId) + ) + execution = { + ...execution, + create: { + ...intent.params, + tabPlacement: { groupId: group?.id, afterTabId: args.source.tabId } + }, + source: { + terminal: captured.handle, + expectedIncarnationId: captured.incarnationId, + originalPresentation: captured.presentation + } + } + } else if ( + args.direction === 'chat' && + !sessionId && + args.history && + adoptsCliConversations(args.history.agent) + ) { + const prepared = await deadline.wait(prepareAiVaultSessionForResume(args.history)) + const intent = createStructuredAgentSessionLaunchIntent( + args.workspaceId, + await deadline.wait(adoptingAgentOnHost(args.history.agent, args.workspaceId, hostId)), + hostId, + { + providerSessionId: prepared.sessionId + }, + undefined, + false + ) + sessionId = intent.sessionId + execution = { ...execution, create: intent.params } + } else if (args.direction === 'terminal') { + const state = useAppStore.getState() + execution = { + ...execution, + sourceTabId: args.chatTabId ?? undefined, + groupId: state.groupsByWorktree[args.workspaceId]?.find((row) => + row.tabOrder.includes(args.chatTabId ?? '') + )?.id, + commandOverride: state.settings?.agentCmdOverrides?.[args.agent] + } + } + const method = + args.direction === 'chat' ? 'agentSession.switchToChat' : 'agentSession.switchToTerminal' + const invoke = () => + deadline.wait( + callStructuredAgentSession( + target, + method, + { sessionId, execution }, + { + signal: deadline.signal, + dispatchObservation: deadline.delegateSend, + beforeSend: (sentAt) => { + deadline.beforeSend(sentAt) + sent = true + } + } + ) + ) + if (args.direction === 'chat' && sessionId) { + presentation = beginSwitchPresentation(target, sessionId) + } + const answer = await invoke() + if (args.direction === 'chat' && answer.sessionId !== sessionId) { + presentation?.release() + presentation = beginSwitchPresentation(target, answer.sessionId) + } + if (answer.status !== 'completed') { + if (answer.status === 'refused') { + selection.discard() + } + if (!presentation || reportSwitchPresentationFailure(presentation, answer.failureItemId)) { + feedback( + conversationSwitchFailureText(new AgentSessionRefusalError(answer.refusal), args), + 'error' + ) + } + return false + } + if (target.kind === 'environment') { + await presentConversationSwitchResult(args, target, answer, viewerSelection) + } + if (answer.sourceRetained) { + feedback(conversationSwitchSourceKept(args.agent), 'info') + } + return true + } catch (error) { + if (!sent) { + selection.discard() + } + if (!presentation || reportSwitchPresentationFailure(presentation)) { + feedback(conversationSwitchFailureText(error, args), 'error') + } + return false + } finally { + if (!consented) { + selection.discard() + } + if (!sent) { + releaseUnsentSwitchProjection(invocationId) + } + settleSwitchPresentation(presentation) + deadline.dispose() + pending.delete(key) + conversationSwitchChanged() + if (consented) { + rederiveConversationSwitch(args) + } + } +} + +export { + useConversationSwitchPending, + useConversationSwitchReadiness, + type ConversationSwitch +} from './conversation-switch-state' diff --git a/src/renderer/src/lib/conversation-switch-feedback.ts b/src/renderer/src/lib/conversation-switch-feedback.ts new file mode 100644 index 000000000000..89a57c80b2e8 --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-feedback.ts @@ -0,0 +1,90 @@ +import { translate } from '@/i18n/i18n' +import { structuredLaunchFailure } from './structured-agent-session-launch-failure' +import { agentLabel } from '../../../shared/ai-vault-session-filters' +import type { AiVaultAgent } from '../../../shared/ai-vault-types' + +export function conversationSwitchFailureText( + error: unknown, + args: { + agent: AiVaultAgent + direction: 'chat' | 'terminal' + source?: unknown + history?: unknown + platform?: string + }, + reasonOverride?: string +): string { + const failure = structuredLaunchFailure(error) + const reason = reasonOverride ?? failure?.details?.reason + const values = { agent: agentLabel(args.agent) } + if (reason === 'conversationHeldElsewhere') { + return failure?.source + ? translate( + 'components.tab.bar.switch.sourceExitUnconfirmed', + 'Orca could not confirm {{agent}} stopped in this terminal. Quit {{agent}} here, then try this action again.', + values + ) + : failure?.owner + ? translate( + 'components.tab.bar.switch.openElsewhere', + '{{agent}} is open in another chat or terminal. Continue there, then try switching again.', + values + ) + : translate( + 'components.tab.bar.switch.writerUnconfirmed', + 'Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try this action again.', + values + ) + } + if (reason === 'ownerUnproven' || reason === 'previousExitUnverifiable') { + return translate( + args.source + ? 'components.tab.bar.switch.sourceStopUnknown' + : 'components.tab.bar.switch.stopUnknown', + args.source + ? 'Orca could not confirm {{agent}} stopped. Check {{agent}} in the original terminal, then try switching again.' + : 'Orca could not confirm {{agent}} stopped. Check its open chats and terminals, then try switching again.', + values + ) + } + if (reason === 'operationExpired' || reason === 'outcomeUnknown' || reason === 'tabUnconfirmed') { + return translate( + 'components.tab.bar.switch.outcomeUnknown', + 'Orca could not confirm the {{agent}} switch. Check the open chat and terminal before trying again.', + values + ) + } + if (reason === 'transcriptNotFound') { + return translate( + 'components.native-chat.resumeInNewChat.notSavedYet', + "{{agent}} hasn't saved this conversation yet. Try again after it replies.", + values + ) + } + if (reason === 'transcriptUnreadable') { + return translate( + 'components.tab.bar.switch.historyUnavailable', + "{{agent}} couldn't open this conversation's history. Check that its saved conversation is available, then try again.", + values + ) + } + return args.direction === 'chat' + ? translate( + 'components.tab.bar.switch.chatStartFailed', + '{{agent}} could not start in chat. Check its setup and try again.', + values + ) + : translate( + 'components.tab.bar.switch.terminalStartFailed', + '{{agent}} could not start in a terminal. Check its setup and try again.', + values + ) +} + +export function conversationSwitchSourceKept(agent: AiVaultAgent): string { + return translate( + 'components.tab.bar.switch.sourceKept', + '{{agent}} opened successfully. The original tab was kept; continue in the new tab.', + { agent: agentLabel(agent) } + ) +} diff --git a/src/renderer/src/lib/conversation-switch-legacy.ts b/src/renderer/src/lib/conversation-switch-legacy.ts new file mode 100644 index 000000000000..7d80e8939097 --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-legacy.ts @@ -0,0 +1,49 @@ +import { toast } from 'sonner' +import { conversationSwitchFailureText } from './conversation-switch-feedback' +import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' +import { runLegacyCliChatResume } from '@/components/tab-bar/cli-chat-resume-run' +import { runLegacyNativeChatCliSwitch } from '@/components/tab-bar/native-chat-cli-switch-run' +import { switchIntoOwnerLegacy } from '@/components/tab-bar/cli-chat-resume-feedback' + +import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch' + +import type { ConversationSwitch } from './conversation-switch-state' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +export function runLegacyConversationSwitch( + args: ConversationSwitch, + target: RuntimeClientTarget, + viewerSelection: string +) { + const reportFailure = (error: unknown) => toast.error(conversationSwitchFailureText(error, args)) + if (args.direction === 'terminal' && args.sessionId) { + return runLegacyNativeChatCliSwitch({ + ...args, + sessionId: args.sessionId, + chatTabId: args.chatTabId ?? null, + viewerSelection, + reportFailure + }) + } else if (args.source) { + return runLegacyCliChatResume(args.source, viewerSelection, reportFailure) + } else if (args.sessionId) { + return switchIntoOwnerLegacy( + { agent: args.agent, tabId: args.chatTabId ?? '', workspaceId: args.workspaceId }, + { sessionId: args.sessionId, workspaceId: args.workspaceId }, + target, + viewerSelection, + reportFailure + ) + } else if (args.history && adoptsCliConversations(args.agent)) { + return resumeAiVaultSessionInNewChat( + args.history, + args.agent, + args.workspaceId, + newAgentLaunchRequestId(), + undefined, + viewerSelection, + reportFailure + ) + } + return undefined +} diff --git a/src/renderer/src/lib/conversation-switch-presentation.test.tsx b/src/renderer/src/lib/conversation-switch-presentation.test.tsx new file mode 100644 index 000000000000..e889bfcc5b15 --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-presentation.test.tsx @@ -0,0 +1,82 @@ +import { useRef } from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { NativeChatNoticeRow } from '@/components/native-chat/NativeChatNoticeRow' +import { + beginSwitchPresentation, + reportSwitchPresentationFailure, + SwitchPresentationContext, + useSwitchPresentation, + useSwitchPresentationRows +} from './conversation-switch-presentation' + +// @vitest-environment happy-dom + +const target = { kind: 'local' as const } +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) +function List({ item }: { item?: string }) { + const root = useRef(null) + useSwitchPresentationRows(root) + return ( +
+ {item && ( +
+ +
+ )} +
+ ) +} +function Visit({ item, visible = true }: { item?: string; visible?: boolean }) { + const visit = useSwitchPresentation(target, 'conversation', visible) + return ( + + + + ) +} +function bounds(top: number): DOMRect { + return { + x: 0, + y: top, + width: 200, + height: 30, + top, + bottom: top + 30, + left: 0, + right: 200, + toJSON: () => ({}) + } +} +it.each([true, false])( + 'reports the exact failure once in the clicked context (visible: %s)', + (visible) => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement + ) { + return bounds(!visible && this.dataset.switchItemId ? 5_000 : 0) + }) + const visit = beginSwitchPresentation(target, 'conversation') + const view = render() + act(() => { + expect(reportSwitchPresentationFailure(visit, 'failed-new')).toBe(!visible) + }) + expect(reportSwitchPresentationFailure(visit, 'failed-new')).toBe(false) + if (visible) { + expect(screen.getByText('Codex could not start. Try again.')).toBeTruthy() + } else { + expect(screen.queryByText('Codex could not start. Try again.')).toBeNull() + } + view.unmount() + visit.release() + render() + expect(screen.getByText('Codex could not start. Try again.')).toBeTruthy() + } +) diff --git a/src/renderer/src/lib/conversation-switch-presentation.tsx b/src/renderer/src/lib/conversation-switch-presentation.tsx new file mode 100644 index 000000000000..e4d02e7cef7a --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-presentation.tsx @@ -0,0 +1,195 @@ +import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from 'react' +import type { RefObject } from 'react' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner' + +export type SwitchPresentationVisit = { + subscribe: (listener: () => void) => () => void + changed: () => void + stopObserving?: () => void + observeRows?: () => void + itemVisible?: (id: string) => boolean + presentedItems: Set + settled: boolean + failureItemId?: string + reporter?: 'inline' | 'clicked' + mounted: boolean + visible: boolean + release: () => void +} +const visits = new Map() +const listeners = new Map void>>() +const subscribe = (key: string, listener: () => void) => { + const rows = listeners.get(key) ?? new Set<() => void>() + rows.add(listener) + listeners.set(key, rows) + return () => { + rows.delete(listener) + if (!rows.size) { + listeners.delete(key) + } + } +} +const changed = (key: string) => listeners.get(key)?.forEach((listener) => listener()) +const keyFor = (target: RuntimeClientTarget, id: string) => + `${executionHostIdForStructuredTarget(target)}\0${id}` +export const SwitchPresentationContext = createContext( + undefined +) + +/** Only this automatic visit owns the click's selected failure; a later manual visit reads history normally. */ +export function beginSwitchPresentation( + target: RuntimeClientTarget, + id: string +): SwitchPresentationVisit { + const key = keyFor(target, id) + visits.get(key)?.release() + const visit: SwitchPresentationVisit = { + subscribe: (listener) => subscribe(key, listener), + changed: () => changed(key), + presentedItems: new Set(), + settled: false, + mounted: false, + visible: false, + release: () => { + if (visits.get(key) === visit) { + visit.stopObserving?.() + visits.delete(key) + changed(key) + } + } + } + visits.set(key, visit) + changed(key) + return visit +} +export function reportSwitchPresentationFailure( + visit: SwitchPresentationVisit, + itemId?: string +): boolean { + if (visit.reporter) { + return false + } + visit.failureItemId = itemId + visit.reporter = + itemId && (visit.presentedItems.has(itemId) || (visit.visible && visit.itemVisible?.(itemId))) + ? 'inline' + : 'clicked' + settleSwitchPresentation(visit) + return visit.reporter === 'clicked' +} +export function settleSwitchPresentation(visit: SwitchPresentationVisit | undefined): void { + if (!visit || visit.settled) { + return + } + visit.settled = true + visit.stopObserving?.() + visit.presentedItems.clear() + visit.itemVisible = undefined + if (!visit.mounted && !visit.reporter) { + visit.release() + } + visit.changed() +} +export function useSwitchPresentation(target: RuntimeClientTarget, id: string, visible: boolean) { + const key = keyFor(target, id) + const visit = useSyncExternalStore( + (listener) => subscribe(key, listener), + () => visits.get(key), + () => undefined + ) + useLayoutEffect(() => { + if (!visit) { + return + } + visit.mounted = true + visit.visible = visible + visit.observeRows?.() + + if (!visible) { + visit.release() + } + return () => { + visit.visible = false + visit.release() + } + }, [visit, visible]) + return visit +} +export function useSwitchPresentationRows(root: RefObject): void { + const visit = useContext(SwitchPresentationContext) + useLayoutEffect(() => { + if (!visit || visit.settled) { + return + } + const rowVisible = (row: HTMLElement): boolean => { + const element = root.current + if (!element || !element.isConnected) { + return false + } + const bounds = element.getBoundingClientRect() + const rect = row.getBoundingClientRect() + return ( + bounds.width > 0 && + bounds.height > 0 && + rect.height > 0 && + rect.width > 0 && + rect.right > Math.max(0, bounds.left) && + rect.left < Math.min(window.innerWidth, bounds.right) && + rect.bottom > Math.max(0, bounds.top) && + rect.top < Math.min(window.innerHeight, bounds.bottom) + ) + } + const itemVisible = (id: string): boolean => { + for (const row of root.current?.querySelectorAll('[data-switch-item-id]') ?? + []) { + if (row.dataset.switchItemId === id) { + return rowVisible(row) + } + } + return false + } + visit.itemVisible = itemVisible + const observe = () => { + if (!visit.visible || visit.settled) { + return + } + for (const row of root.current?.querySelectorAll('[data-switch-item-id]') ?? + []) { + const id = row.dataset.switchItemId + if (id && rowVisible(row)) { + visit.presentedItems.add(id) + } + } + } + observe() + const element = root.current + element?.addEventListener('scroll', observe, { passive: true }) + const stop = () => { + element?.removeEventListener('scroll', observe) + if (visit.itemVisible === itemVisible) { + visit.itemVisible = undefined + } + if (visit.stopObserving === stop) { + visit.stopObserving = undefined + } + if (visit.observeRows === observe) { + visit.observeRows = undefined + } + } + visit.stopObserving = stop + visit.observeRows = observe + return () => { + observe() + stop() + } + }) +} +export function useSwitchFailureSuppressed(itemId: string | undefined): boolean { + const visit = useContext(SwitchPresentationContext) + return useSyncExternalStore( + visit?.subscribe ?? (() => () => {}), + () => Boolean(itemId && visit?.failureItemId === itemId && visit.reporter === 'clicked'), + () => false + ) +} diff --git a/src/renderer/src/lib/conversation-switch-projection.ts b/src/renderer/src/lib/conversation-switch-projection.ts new file mode 100644 index 000000000000..975c4a40c4b0 --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-projection.ts @@ -0,0 +1,301 @@ +import type { AppState } from '@/store' +import type { Tab } from '../../../shared/tab-types' +import { + AGENT_SESSION_SWITCH_CLIENT_MS, + type SessionSwitchProjection +} from '../../../shared/agent-session-switch-execution' +import { recordWebSessionCloseIntent } from '@/runtime/web-session-close-intent' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-owner' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createKeptWorktreeSurfacePruners } from '@/store/slices/tabs/kept-worktree-surface-pruning' + +type Guard = { + proposal: SessionSwitchProjection + workspaceId: string + tab?: Tab + placement: string + used: boolean + notified: boolean + releaseClose?: () => void + retain: (converged?: boolean) => void +} +const guards = new Map() +function placement(state: Pick, workspaceId: string, tab?: Tab) { + const group = state.groupsByWorktree[workspaceId]?.find((row) => row.id === tab?.groupId) + return JSON.stringify([ + tab?.id, + tab?.entityId, + tab?.groupId, + group?.tabOrder.indexOf(tab?.id ?? '') + ]) +} + +/** Only active handoffs own guards; ordinary tab use never waits for a host receipt. */ +export function captureSwitchProjection( + state: AppState, + workspaceId: string, + proposal: SessionSwitchProjection, + retain: (converged?: boolean) => void +): void { + const provisional = [...guards.entries()].find( + ([, guard]) => guard.proposal.invocationId === proposal.invocationId + ) + const previous = guards.get(proposal.generation) ?? provisional?.[1] + if (previous && provisional && provisional[0] !== proposal.generation) { + guards.delete(provisional[0]) + guards.set(proposal.generation, previous) + previous.proposal = proposal + } + if (previous?.proposal.invocationId === proposal.invocationId) { + previous.retain = retain + if (previous.used) { + previous.notified = true + queueMicrotask(retain) + } + return + } + for (const [generation, guard] of guards) { + if (guard.workspaceId !== workspaceId || guard.proposal.sessionId !== proposal.sessionId) { + continue + } + guard.releaseClose?.() + guard.retain() + guards.delete(generation) + } + const tab = state.unifiedTabsByWorktree[workspaceId]?.find( + (row) => + row.id === proposal.tabId || + (row.contentType === 'agent-session' && row.entityId === proposal.sessionId) + ) + guards.set(proposal.generation, { + proposal, + workspaceId, + tab, + placement: placement(state, workspaceId, tab), + used: false, + notified: false, + retain + }) +} +export function noteSwitchPresentationUse(tabOrSessionId: string): void { + for (const guard of guards.values()) { + if (guard.tab?.id === tabOrSessionId || guard.proposal.sessionId === tabOrSessionId) { + guard.releaseClose?.() + guard.used = true + if (!guard.notified) { + guard.notified = true + queueMicrotask(guard.retain) + } + } + } +} +export function observeSwitchProjections( + state: AppState, + snapshot: RuntimeMobileSessionTabsResult, + retain: (proposal: SessionSwitchProjection, converged?: boolean) => void +): void { + for (const proposal of snapshot.switchProjections ?? []) { + if (proposal.phase === 'prepare') { + captureSwitchProjection(state, snapshot.worktree, proposal, (converged) => + retain(proposal, converged) + ) + } + const guard = guards.get(proposal.generation) + if (guard) { + guard.proposal = proposal + if (proposal.phase === 'retain') { + guard.releaseClose?.() + } + } + } +} + +/** A switch projection changes presentation only, never drafts, readers or provider ownership. */ +export function retireSwitchProjection( + state: AppState, + proposal: SessionSwitchProjection +): Partial | null { + const guard = guards.get(proposal.generation) + if (!guard || !guard.tab || guard.used) { + return null + } + const tab = state.unifiedTabsByWorktree[guard.workspaceId]?.find( + (row) => row.id === guard.tab!.id + ) + if (!tab || placement(state, guard.workspaceId, tab) !== guard.placement) { + noteSwitchPresentationUse(proposal.sessionId) + return null + } + const prune = createKeptWorktreeSurfacePruners(state, { + worktreeIds: new Set([guard.workspaceId]), + unifiedTabIds: new Set([tab.id]), + terminalTabIds: new Set(), + fileIds: new Set(), + browserTabIds: new Set() + }) + guard.releaseClose = recordWebSessionCloseIntent( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + guard.workspaceId, + `agent-session:${proposal.sessionId}`, + Date.now(), + AGENT_SESSION_SWITCH_CLIENT_MS + ) + return { + unifiedTabsByWorktree: prune.unifiedTabs(state.unifiedTabsByWorktree), + groupsByWorktree: prune.groups(state.groupsByWorktree), + tabBarOrderByWorktree: prune.tabBarOrder(state.tabBarOrderByWorktree), + activeTabIdByWorktree: prune.activeTabIds(state.activeTabIdByWorktree) + } +} + +/** Recheck inside the actual producer, including prepared patches and later omission frames. */ +export function protectSwitchProjections< + State extends Pick & + Partial> +>(state: State, patch: Partial): Partial { + const workspaces = new Map< + string, + { + current: Map + present: Set + placements: Map + restored: Tab[] + } + >() + for (const guard of guards.values()) { + const rows = patch.unifiedTabsByWorktree?.[guard.workspaceId] + if (!rows || !guard.tab) { + continue + } + let indexed = workspaces.get(guard.workspaceId) + if (!indexed) { + const currentRows = state.unifiedTabsByWorktree[guard.workspaceId] ?? [] + const positions = new Map() + const groupIds = new Set( + (state.groupsByWorktree[guard.workspaceId] ?? []).map((group) => group.id) + ) + for (const group of state.groupsByWorktree[guard.workspaceId] ?? []) { + group.tabOrder.forEach((id, index) => positions.set(id, index)) + } + indexed = { + current: new Map(currentRows.map((tab) => [tab.id, tab])), + present: new Set(rows.map((tab) => tab.id)), + placements: new Map( + currentRows.map((tab) => [ + tab.id, + JSON.stringify([ + tab.id, + tab.entityId, + tab.groupId, + positions.get(tab.id) ?? (groupIds.has(tab.groupId ?? '') ? -1 : undefined) + ]) + ]) + ), + restored: [] + } + workspaces.set(guard.workspaceId, indexed) + } + const current = indexed.current.get(guard.tab.id) + if (current && indexed.placements.get(current.id) !== guard.placement) { + guard.releaseClose?.() + guard.used = true + } + const present = indexed.present.has(guard.tab.id) + if ( + (guard.proposal.phase === 'retain' && present) || + (guard.proposal.phase === 'retire' && !guard.used && !present) + ) { + guard.releaseClose?.() + guards.delete(guard.proposal.generation) + queueMicrotask(() => guard.retain(true)) + continue + } + if (!guard.used || present) { + continue + } + if (!guard.notified) { + guard.notified = true + queueMicrotask(guard.retain) + } + const kept = current ?? guard.tab + indexed.restored.push(kept) + indexed.present.add(kept.id) + } + let next = patch + for (const [workspaceId, indexed] of workspaces) { + if (!indexed.restored.length) { + continue + } + const currentGroups = new Map( + (state.groupsByWorktree[workspaceId] ?? []).map((group) => [group.id, group]) + ) + const additions = new Map() + for (const tab of indexed.restored) { + if (!tab.groupId) { + continue + } + const ids = additions.get(tab.groupId) ?? [] + ids.push(tab.id) + additions.set(tab.groupId, ids) + } + const groups = ( + patch.groupsByWorktree?.[workspaceId] ?? + state.groupsByWorktree[workspaceId] ?? + [] + ).map((group) => { + const ids = additions.get(group.id) + additions.delete(group.id) + if (!ids) { + return group + } + const present = new Set(group.tabOrder) + return { ...group, tabOrder: [...group.tabOrder, ...ids.filter((id) => !present.has(id))] } + }) + for (const id of additions.keys()) { + const group = currentGroups.get(id) + if (group) { + groups.push(group) + } + } + next = { + ...next, + unifiedTabsByWorktree: { + ...(next.unifiedTabsByWorktree ?? state.unifiedTabsByWorktree), + [workspaceId]: [...(patch.unifiedTabsByWorktree?.[workspaceId] ?? []), ...indexed.restored] + }, + groupsByWorktree: { + ...(next.groupsByWorktree ?? state.groupsByWorktree), + [workspaceId]: groups + }, + ...(indexed.restored.some((tab) => state.activeTabIdByWorktree?.[workspaceId] === tab.id) + ? { + activeTabIdByWorktree: { + ...next.activeTabIdByWorktree, + ...state.activeTabIdByWorktree + }, + activeGroupIdByWorktree: { + ...next.activeGroupIdByWorktree, + ...state.activeGroupIdByWorktree + } + } + : {}) + } + } + return next +} + +/** Teardown retains unresolved sources before releasing this view's transient protection. */ +export function disposeSwitchProjections(): void { + for (const guard of guards.values()) { + guard.releaseClose?.() + guard.retain() + } + guards.clear() +} + +export function releaseUnsentSwitchProjection(invocationId: string): void { + const guard = guards.get(invocationId) + if (guard?.proposal.invocationId === invocationId) { + guards.delete(invocationId) + } +} diff --git a/src/renderer/src/lib/conversation-switch-result-selection.ts b/src/renderer/src/lib/conversation-switch-result-selection.ts new file mode 100644 index 000000000000..b903d3eb1f8f --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-result-selection.ts @@ -0,0 +1,52 @@ +import type { AgentSessionSwitchResult } from '../../../shared/agent-session-switch-execution' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { refreshWebRuntimeSessionTabsSnapshot } from '@/runtime/web-runtime-session-snapshot' +import { + activateStructuredAgentSessionById, + findStructuredAgentSessionTab +} from './agent-session/structured-agent-session-tab-activation' +import { useAppStore } from '@/store' +import { presentAgentLaunchTab } from './agent-launch-tab-presentation' +import { conversationSwitchViewerSelectionCurrent } from './conversation-switch-selection' +import type { ConversationSwitch } from './conversation-switch-state' + +/** The owning host published membership; this caller alone may move its viewer. */ +export async function presentConversationSwitchResult( + args: ConversationSwitch, + target: Extract, + result: Extract, + viewerSelection: string +): Promise { + const workspaceId = + (args.direction === 'chat' + ? findStructuredAgentSessionTab(useAppStore.getState().unifiedTabsByWorktree, { + sessionId: result.sessionId, + target + })?.worktreeId + : undefined) ?? args.workspaceId + await refreshWebRuntimeSessionTabsSnapshot(target.environmentId, workspaceId, { + acceptCurrentSnapshot: true, + afterCurrentInFlight: true + }).catch(() => {}) + if (!conversationSwitchViewerSelectionCurrent(viewerSelection)) { + return + } + if (args.direction === 'chat') { + activateStructuredAgentSessionById({ + worktreeId: workspaceId, + sessionId: result.sessionId, + target, + callerOnly: true, + viewerSelection + }) + } else if (result.terminal) { + presentAgentLaunchTab( + 'focus-window', + args.workspaceId, + toWebTerminalSurfaceTabId(result.terminal), + undefined, + viewerSelection + ) + } +} diff --git a/src/renderer/src/lib/conversation-switch-selection.ts b/src/renderer/src/lib/conversation-switch-selection.ts new file mode 100644 index 000000000000..14b04076fcae --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-selection.ts @@ -0,0 +1,48 @@ +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { viewerSelectionOutcome } from './viewer-selection-request' +import { structuredAgentSessionDomainFingerprint } from '../../../shared/structured-agent-session-mutation' +import { useAppStore } from '@/store' +import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' + +/** Presentation freshness only; the execution host retains all process authority. */ +type ViewerState = Parameters[0] & + Partial< + Pick< + ReturnType, + | 'activeWorkspaceExecutionHostId' + | 'activeView' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + > + > + +export function captureConversationSwitchViewerSelection( + state: ViewerState = useAppStore.getState() +): string { + const workspaceId = state.activeWorktreeId ?? '' + return structuredAgentSessionDomainFingerprint({ + domain: 'conversation-switch-viewer', + sessionId: workspaceId, + fields: { + workspaceId: state.activeWorktreeId, + executionHostId: state.activeWorkspaceExecutionHostId, + view: state.activeView, + history: state.worktreeNavHistory, + historyIndex: state.worktreeNavHistoryIndex, + groupId: state.activeGroupIdByWorktree?.[workspaceId], + tabId: resolveWebSessionVisibleTabId(state, workspaceId) + } + }) +} + +export function conversationSwitchViewerSelectionCurrent( + selection?: string, + state?: ViewerState +): boolean { + return ( + selection === undefined || + (isSessionTabSelectionRequest(selection) + ? viewerSelectionOutcome(selection) === 'pending' + : selection === captureConversationSwitchViewerSelection(state)) + ) +} diff --git a/src/renderer/src/lib/conversation-switch-state.ts b/src/renderer/src/lib/conversation-switch-state.ts new file mode 100644 index 000000000000..8c2fe57e2cb5 --- /dev/null +++ b/src/renderer/src/lib/conversation-switch-state.ts @@ -0,0 +1,143 @@ +import { resolveWorktreeExecutionHostPlatform } from './execution-host-facts' +import { translate } from '@/i18n/i18n' +import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' +import { runningStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { useInPlaceSwitchCapability } from './in-place-switch-support' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { refreshLocalStructuredSessionWorktreeTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh' +import { + captureConversationSwitchViewerSelection, + conversationSwitchViewerSelectionCurrent +} from './conversation-switch-selection' +import { + pendingNativeChatCliSwitches as pending, + pendingCliChatResumes, + subscribeConversationSwitch as subscribe +} from './pending-conversation-surface-switches' +import type { AiVaultSession, AiVaultAgent } from '../../../shared/ai-vault-types' +import type { CliChatResumeSource } from '@/components/tab-bar/cli-chat-resume-source' + +export type ConversationSwitch = { + legacyContinue?: (viewerSelection: string) => Promise + confirm?: ConfirmationDialogContextValue + direction: 'chat' | 'terminal' + executionHostId: string + workspaceId: string + agent: AiVaultAgent + sessionId?: string + chatTabId?: string | null + source?: CliChatResumeSource + history?: AiVaultSession + platform?: string + feedback?: (message: string, kind: 'error' | 'info') => void +} + +export function withConversationSwitchPlatform(args: ConversationSwitch): ConversationSwitch { + const platform = resolveWorktreeExecutionHostPlatform( + useAppStore.getState(), + args.workspaceId, + undefined + ) + return { + ...args, + platform: args.platform ?? (platform.kind === 'known' ? platform.platform : undefined) + } +} + +export const keyFor = (args: ConversationSwitch) => + `${args.executionHostId}\0${args.sessionId ?? args.source?.providerSessionId ?? args.history?.sessionId}` + +export function pendingNow(args: ConversationSwitch) { + return ( + pending.has(keyFor(args)) || + Boolean(args.sessionId && pending.has(`${args.executionHostId}\0${args.sessionId}`)) || + [...pendingCliChatResumes.values()].some( + ({ source }) => + source.executionHostId === args.executionHostId && + source.providerSessionId === args.source?.providerSessionId + ) + ) +} + +export function useConversationSwitchPending(args: ConversationSwitch | null): boolean { + return useSyncExternalStore( + subscribe, + () => Boolean(args && pendingNow(args)), + () => false + ) +} + +/** Live facts choose consent only; never open a reader to classify saved work. */ +export function conversationSwitchReplying(args: ConversationSwitch): boolean { + const state = useAppStore.getState() + if (args.source) { + return state.agentStatusByPaneKey[args.source.paneKey]?.state === 'working' + } + const target = structuredAgentSessionTargetForHost(args.executionHostId) + if (!args.sessionId || !target) { + return false + } + const held = findStructuredAgentSessionReadOwner(args.sessionId, target)?.getSnapshot() + return Boolean( + (held?.current && + held.state.working !== false && + runningStructuredAgentSessionTurnId(held.state)) || + getStructuredAgentSessionStatusFeed(target).getSnapshot().get(args.sessionId)?.status === + 'working' + ) +} + +export function useConversationSwitchReadiness(args: ConversationSwitch | null) { + const capability = useInPlaceSwitchCapability(args?.executionHostId, args?.agent) + const pending = useConversationSwitchPending(args) + const updateRequired = args?.source?.hostUpdateRequired === true + return { + ready: Boolean( + args && + structuredAgentSessionTargetForHost(args.executionHostId) && + capability !== undefined && + !pending && + !updateRequired + ), + pending, + explanation: updateRequired + ? translate( + 'components.native-chat.resumeInNewChat.hostUpdateRequired', + 'Update Orca on this host to continue in Chat UI.' + ) + : undefined + } +} + +export function captureConversationSwitchSelection( + selection = captureConversationSwitchViewerSelection() +): () => boolean { + return () => conversationSwitchViewerSelectionCurrent(selection) +} + +export function rederiveConversationSwitch(args: ConversationSwitch): void { + const target = structuredAgentSessionTargetForHost(args.executionHostId) + if (!target) { + return + } + if (args.sessionId) { + findStructuredAgentSessionReadOwner(args.sessionId, target)?.resync?.() + } + const refresh = + target.kind === 'local' + ? refreshLocalStructuredSessionWorktreeTabs(args.workspaceId, { + reacceptCurrentVersion: true + }) + : import('@/runtime/web-runtime-session-snapshot').then( + ({ refreshWebRuntimeSessionTabsSnapshot }) => + refreshWebRuntimeSessionTabsSnapshot(target.environmentId, args.workspaceId, { + acceptCurrentSnapshot: true, + afterCurrentInFlight: true + }) + ) + void refresh.catch(() => {}) +} diff --git a/src/renderer/src/lib/in-place-switch-support.ts b/src/renderer/src/lib/in-place-switch-support.ts new file mode 100644 index 000000000000..4f113d542f4e --- /dev/null +++ b/src/renderer/src/lib/in-place-switch-support.ts @@ -0,0 +1,118 @@ +import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown, + subscribeLocalRuntimeCapabilityObservation +} from '@/runtime/local-runtime-capabilities' +import { + isRuntimeHostContactRevoked, + lastVerifiedRuntimeStatus +} from '../../../shared/runtime-host-status' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + onRuntimeEnvironmentRevisionsChanged, + onRuntimeEnvironmentsRetired +} from '@/runtime/runtime-environment-revision' +import { + inPlaceSwitchCapability, + inPlaceSwitchExecutionCapability, + inPlaceSwitchEnabled +} from '../../../shared/structured-agent-cli-conversation-adoption' +import type { AiVaultAgent } from '../../../shared/ai-vault-types' + +const listeners = new Set<() => void>() +const changed = () => { + listeners.forEach((listener) => listener()) +} +onRuntimeEnvironmentRevisionsChanged(changed) +onRuntimeEnvironmentsRetired(changed) +subscribeLocalRuntimeCapabilityObservation(changed) +let stopStatusSubscription: (() => void) | undefined +const subscribe = (listener: () => void) => { + const activate = listeners.size === 0 + if (activate) { + stopStatusSubscription = useAppStore.subscribe((state, previous) => { + if ( + state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId || + state.runtimeEnvironments !== previous.runtimeEnvironments + ) { + changed() + } + }) + } + listeners.add(listener) + if (activate) { + void ensureLocalRuntimeCapabilities() + } + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + stopStatusSubscription?.() + stopStatusSubscription = undefined + } + } +} + +/** The addressed runtime owner's learned answer; reading never starts a probe. */ +export function inPlaceSwitchCapabilityNow( + executionHostId: string | null | undefined, + agent: string, + execution = false +): boolean | undefined { + if (!inPlaceSwitchEnabled(agent)) { + return false + } + const target = structuredAgentSessionTargetForHost(executionHostId) + if (!target) { + return undefined + } + if (target.kind === 'local') { + return readLocalRuntimeCapabilitiesOrUnknown()?.includes( + execution ? inPlaceSwitchExecutionCapability(agent) : inPlaceSwitchCapability(agent) + ) + } + const state = useAppStore.getState() + if (!state.runtimeEnvironments.some((environment) => environment.id === target.environmentId)) { + return undefined + } + const entry = state.runtimeStatusByEnvironmentId.get(target.environmentId) + const status = isRuntimeHostContactRevoked(entry) ? null : lastVerifiedRuntimeStatus(entry) + return status + ? (status.capabilities ?? []).includes( + execution ? inPlaceSwitchExecutionCapability(agent) : inPlaceSwitchCapability(agent) + ) + : undefined +} + +export function inPlaceSwitchSupportedNow(host: string | null | undefined, agent: string): boolean { + return inPlaceSwitchCapabilityNow(host, agent) === true +} + +/** Whether the host running this conversation switches it in place; false until it answered yes. */ +export function useInPlaceSwitchCapability( + executionHostId: string | null | undefined, + agent: string | null | undefined +): boolean | undefined { + return useSyncExternalStore( + subscribe, + () => (agent ? inPlaceSwitchCapabilityNow(executionHostId, agent) : false), + () => false + ) +} + +export function useInPlaceSwitchSupported( + host: string | null | undefined, + agent: string | null | undefined +): boolean { + return useInPlaceSwitchCapability(host, agent) === true +} + +/** A History row's agent when its host switches it in place: the row offers the switch, not a copy. */ +export function useAiVaultInPlaceAgent(session: { + agent: AiVaultAgent + executionHostId?: string | null +}): AiVaultAgent | undefined { + useInPlaceSwitchCapability(session.executionHostId, session.agent) + return inPlaceSwitchEnabled(session.agent) ? session.agent : undefined +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 922464a82629..2f13147a6ace 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { + registerEagerPtyBuffer, + subscribeToPtyExit +} from '@/components/terminal-pane/pty-dispatcher' +import type { createTestStore } from '@/store/slices/store-test-helpers' +import type { AppState } from '@/store/types' import { AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, createAgentBackgroundSessionTestState, @@ -21,9 +27,9 @@ const mockUpdateTabPtyId = vi.fn() const mockCloseTab = vi.fn() const mockSetTabLayout = vi.fn() const mockRegisterAgentLaunchConfig = vi.fn() -const mockRegisterEagerPtyBuffer = vi.fn() +const mockRegisterEagerPtyBuffer = vi.fn() const mockSubscribeToPtyData = vi.fn() -const mockSubscribeToPtyExit = vi.fn() +const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockDispatchEvent = vi.fn() const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() @@ -37,11 +43,14 @@ const state = createAgentBackgroundSessionTestState({ registerAgentLaunchConfig: mockRegisterAgentLaunchConfig }) let currentStoreState = state +let bindingStore: ReturnType | null = null vi.mock('@/store', () => ({ useAppStore: { - getState: () => currentStoreState, - subscribe: vi.fn(() => () => {}) + getState: () => bindingStore?.getState() ?? currentStoreState, + subscribe: vi.fn((listener: (state: AppState, previous: AppState) => void) => + bindingStore ? bindingStore.subscribe(listener) : () => {} + ) } })) @@ -82,6 +91,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { currentStoreState = state + bindingStore = null resetAgentBackgroundSessionTestHarness({ state, createTab: mockCreateTab, @@ -457,8 +467,11 @@ describe('launchAgentBackgroundSession', () => { onExit }) - const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - sidecar(0) + const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] + if (!sidecar) { + throw new Error('Background exit sidecar was not registered') + } + sidecar(0, { hadPrimary: true }) const tabId = expectReservedAgentBackgroundTabId(mockSpawn) expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1') @@ -470,6 +483,103 @@ describe('launchAgentBackgroundSession', () => { expect(state.markUnverifiedPtyLoss).not.toHaveBeenCalled() }) + it.each(['eager', 'persistent sidecar'] as const)( + 'retains a closing background binding through %s completion but clears an ordinary exit once', + async (entrypoint) => { + const { createTestStore, seedStore, makeWorktree, TEST_REPO } = + await import('@/store/slices/store-test-helpers') + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + const store = createTestStore() + bindingStore = store + seedStore(store, { + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'local' })] + } + }) + const unsubscribeData = vi.fn() + const unsubscribeExit = vi.fn() + mockSubscribeToPtyData.mockReturnValue(unsubscribeData) + mockSubscribeToPtyExit.mockReturnValue(unsubscribeExit) + const onExit = vi.fn() + const result = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onExit + }) + if (!result) { + throw new Error('Background launch did not create a terminal') + } + const tab = store.getState().tabsByWorktree['wt-1'].find((tab) => tab.id === result.tabId) + const layout = store.getState().terminalLayoutsByTabId[result.tabId] + expect(tab?.ptyId).toBe('pty-1') + expect(store.getState().ptyIdsByTabId[result.tabId]).toEqual(['pty-1']) + expect(store.getState().lastKnownRelayPtyIdByTabId[result.tabId]).toBe('pty-1') + expect(store.getState().agentLaunchConfigByPaneKey[result.paneKey]).toBeDefined() + + const eager = mockRegisterEagerPtyBuffer.mock.calls[0]?.[1] + const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] + if (!eager || !sidecar) { + throw new Error('Background exit observers were not registered') + } + const exitOptions = { preserveRendererBinding: 'closing' as const } + if (entrypoint === 'eager') { + eager(result.ptyId, 0, exitOptions) + } else { + sidecar(0, { hadPrimary: true, exitOptions }) + } + // A primary and the captured persistent sidecar may both see the same physical exit. + sidecar(0, { hadPrimary: true, exitOptions }) + expect(onExit).toHaveBeenCalledExactlyOnceWith('pty-1', 0, exitOptions) + expect(onExit.mock.calls[0]?.[2]).toBe(exitOptions) + expect(unsubscribeData).toHaveBeenCalledOnce() + expect(unsubscribeExit).toHaveBeenCalledOnce() + expect(store.getState().agentLaunchConfigByPaneKey[result.paneKey]).toBeUndefined() + expect(store.getState().tabsByWorktree['wt-1'].find((t) => t.id === result.tabId)).toBe(tab) + expect(store.getState().terminalLayoutsByTabId[result.tabId]).toBe(layout) + expect(store.getState().ptyIdsByTabId[result.tabId]).toEqual(['pty-1']) + expect(store.getState().lastKnownRelayPtyIdByTabId[result.tabId]).toBe('pty-1') + expect(store.getState().unverifiedPtyLossTabIds[result.tabId]).toBeUndefined() + + mockSpawn.mockResolvedValueOnce({ id: 'pty-ordinary' }) + const ordinaryExit = vi.fn() + const ordinary = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'finish normally', + onExit: ordinaryExit + }) + if (!ordinary) { + throw new Error('Ordinary background launch did not create a terminal') + } + const ordinaryEager = mockRegisterEagerPtyBuffer.mock.calls[1]?.[1] + const ordinarySidecar = mockSubscribeToPtyExit.mock.calls[1]?.[1] + if (!ordinaryEager || !ordinarySidecar) { + throw new Error('Ordinary exit observers missing') + } + if (entrypoint === 'eager') { + ordinaryEager(ordinary.ptyId, 0) + } else { + ordinarySidecar(0, { hadPrimary: true }) + } + ordinarySidecar(0, { hadPrimary: true }) + expect(ordinaryExit).toHaveBeenCalledExactlyOnceWith('pty-ordinary', 0) + expect(unsubscribeData).toHaveBeenCalledTimes(2) + expect(unsubscribeExit).toHaveBeenCalledTimes(2) + expect(store.getState().agentLaunchConfigByPaneKey[ordinary.paneKey]).toBeUndefined() + expect( + store.getState().tabsByWorktree['wt-1'].find((t) => t.id === ordinary.tabId)?.ptyId + ).toBeNull() + expect(store.getState().ptyIdsByTabId[ordinary.tabId]).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId[ordinary.tabId]).toBeUndefined() + expect(store.getState().unverifiedPtyLossTabIds[ordinary.tabId]).toBeUndefined() + expect(store.getState().tabsByWorktree['wt-1'].find((t) => t.id === result.tabId)).toBe(tab) + expect(mockSpawn).toHaveBeenCalledTimes(2) + result.terminalOwnership?.release() + ordinary.terminalOwnership?.release() + } + ) + it('keeps the tab bound to its PTY when contact was lost rather than observed', async () => { // Synthetic -1 loss retires transport only; retain bindings for reconnect adoption and orphan-cleanup protection. mockSubscribeToPtyExit.mockReturnValue(vi.fn()) @@ -483,8 +593,11 @@ describe('launchAgentBackgroundSession', () => { onExit }) - const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - sidecar(-1) + const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] + if (!sidecar) { + throw new Error('Background exit sidecar was not registered') + } + sidecar(-1, { hadPrimary: true }) const tabId = expectReservedAgentBackgroundTabId(mockSpawn) expect(state.clearTabPtyId).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 44270133e452..0b621b89f434 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -18,6 +18,7 @@ import { type EagerPtyHandle } from '@/components/terminal-pane/pty-dispatcher' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' +import type { PtyExitOptions } from '@/components/terminal-pane/pty-exit-delivery' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { retireProvider } from '@/lib/retire-unowned-background-terminal' @@ -113,7 +114,7 @@ export async function launchAgentBackgroundSession( let unsubscribeExit = (): void => {}, unsubscribeData = (): void => {}, releaseRuntimeObservers = (): void => {} - const handleExit = (exitPtyId: string, code: number): void => { + const handleExit = (exitPtyId: string, code: number, options?: PtyExitOptions): void => { if (exitHandled) { return } @@ -121,10 +122,14 @@ export async function launchAgentBackgroundSession( unsubscribeExit() unsubscribeData() if (tab) { - settleTabPtyBinding(tab.id, exitPtyId, code) + settleTabPtyBinding(tab.id, exitPtyId, code, options) } useAppStore.getState().clearAgentLaunchConfig(paneKey) - onExit?.(exitPtyId, code) + if (options?.preserveRendererBinding) { + onExit?.(exitPtyId, code, options) + } else { + onExit?.(exitPtyId, code) + } } // Main owns local/SSH status scanning; runtime bytes still need renderer-side writes. const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ @@ -269,7 +274,9 @@ export async function launchAgentBackgroundSession( eagerPtyBuffer = registerEagerPtyBuffer(ptyId, handleExit, spawned.incarnationId) unsubscribeData = subscribeToPtyData(ptyId, handleData) // A mounted transport disposes the eager exit handler; this sidecar preserves completion tracking across hidden/visible transitions. - unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) + unsubscribeExit = subscribeToPtyExit(ptyId, (code, context) => + handleExit(ptyId, code, context.exitOptions) + ) } // Bind PTY/ownership before mount to prevent duplicate spawn without missing user takeover. requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) diff --git a/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts b/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts index 306487c4842f..24ea69d5137a 100644 --- a/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts +++ b/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts @@ -8,7 +8,8 @@ import { agentLabel } from '@/components/right-sidebar/ai-vault-session-filters' export async function launchAiVaultSessionAndReveal( args: Parameters[0], - describeFailure: (message: string) => string = (message) => message + describeFailure: (message: string) => string = (message) => message, + queuedMessage?: string ): Promise { const launch = launchAiVaultSessionInNewTab(args) if (launch.tabId === null) { @@ -25,8 +26,15 @@ export async function launchAiVaultSessionAndReveal( return } } - if (useAppStore.getState().activeWorktreeId !== args.worktreeId) { + if ( + args.viewerSelection === undefined && + useAppStore.getState().activeWorktreeId !== args.worktreeId + ) { activateAiVaultResumeWorkspace(args.worktreeId) } - notifyAiVaultSessionQueued(args.agent) + if (queuedMessage === undefined) { + notifyAiVaultSessionQueued(args.agent) + } else { + notifyAiVaultSessionQueued(args.agent, queuedMessage) + } } diff --git a/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts b/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts new file mode 100644 index 000000000000..c9dbd82dd152 --- /dev/null +++ b/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { + beginViewerSelectionRequest, + disposeViewerSelection, + viewerSelectionOutcome +} from './viewer-selection-request' +import { activateTabNumberShortcut } from './tab-number-shortcuts' +import { expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session' + +const mocks = vi.hoisted(() => { + const held: { store: unknown } = { store: null } + return held +}) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + runtimeEnvironmentIdOrLocalUnlessTwin: () => null +})) +vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ + beginStructuredAgentSessionTabClose: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +createTabsSliceMockApi() + +it('keeps the pinned partition and neighbor order after a local switch retires its source', () => { + const worktreeId = 'repo1::/work/app' + const groupId = 'group-1' + const tab = (id: string, extra: Partial = {}): Tab => ({ + id, + entityId: id, + groupId, + worktreeId, + contentType: 'browser', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...extra + }) + const source = tab('agent-session:chat', { + entityId: 'chat', + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local', + isPinned: true + }) + const store = createTestStore() + mocks.store = store + store.setState({ + unifiedTabsByWorktree: { + [worktreeId]: [source, tab('pinned', { isPinned: true }), tab('unpinned')] + }, + groupsByWorktree: { + [worktreeId]: [ + { + id: groupId, + worktreeId, + activeTabId: source.id, + tabOrder: [source.id, 'pinned', 'unpinned'] + } + ] + }, + activeGroupIdByWorktree: { [worktreeId]: groupId }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId } } + }) + + const result = launchAiVaultSessionInNewTab({ + agent: 'codex', + worktreeId, + command: 'codex resume provider-1', + afterTabId: source.id, + resumeOrigin: 'switch' + }) + expect(result.tabId).toEqual(expect.any(String)) + store.getState().closeUnifiedTab(source.id) + + expect(store.getState().groupsByWorktree[worktreeId]?.[0]?.tabOrder).toEqual([ + 'pinned', + result.tabId, + 'unpinned' + ]) + expect(store.getState().unifiedTabsByWorktree[worktreeId]?.some((t) => t.id === source.id)).toBe( + false + ) +}) + +it.each([false, true])( + 'old Continue ordinary create consumes the original selection, newer reclick=%s', + (navigate) => { + const store = createTestStore() + mocks.store = store + const worktreeId = 'repo1::/work/app' + store.setState({ activeWorktreeId: worktreeId, activeView: 'terminal' }) + const source = store.getState().createTab(worktreeId) + const deadline = agentSessionSwitchDeadline(Date.now) + try { + const selection = beginViewerSelectionRequest('old-continue', deadline) + selection.accept() + if (navigate) { + expect(activateTabNumberShortcut(0)).toBe(true) + } + const result = launchAiVaultSessionInNewTab({ + agent: 'codex', + worktreeId, + command: 'codex resume copied-conversation', + viewerSelection: selection.token + }) + expect(result.tabId).not.toBeNull() + expect(store.getState().tabsByWorktree[worktreeId]).toHaveLength(2) + expect(store.getState().getActiveTab(worktreeId)?.entityId).toBe( + navigate ? source.id : result.tabId + ) + expect(viewerSelectionOutcome(selection.token)).toBe(navigate ? 'superseded' : 'fulfilled') + expect(store.getState().pendingStartupByTabId[result.tabId ?? '']?.command).toBe( + 'codex resume copied-conversation' + ) + } finally { + deadline.dispose() + disposeViewerSelection() + } + } +) diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 9c1d45792e61..18b354afc088 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -14,12 +14,14 @@ const runtimeMocks = vi.hoisted(() => ({ isWebRuntimeSessionActive: vi.fn(() => false) })) +const unifiedTabsByWorktree: Record = {} const mockState = { createTab: mockCreateTab, createEmptySplitGroup: mockCreateEmptySplitGroup, closeEmptyGroup: mockCloseEmptyGroup, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, + unifiedTabsByWorktree, tabsByWorktree: {} as Record, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, @@ -64,6 +66,7 @@ describe('launchAiVaultSessionInNewTab', () => { runtimeMocks.runtimeEnvironmentIdOrLocalUnlessTwin.mockReturnValue(null) runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + mockState.unifiedTabsByWorktree = {} mockState.tabsByWorktree = {} mockState.openFiles = [] mockState.browserTabsByWorktree = {} diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 0827b314878b..31d68483d207 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -1,5 +1,8 @@ +import { conversationSwitchViewerSelectionCurrent } from './conversation-switch-selection' +import { presentAgentLaunchTab } from './agent-launch-tab-presentation' import { useAppStore } from '@/store' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' +import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' @@ -27,8 +30,25 @@ export function launchAiVaultSessionInNewTab(args: { providerSession?: AgentProviderSessionMetadata targetGroupId?: string splitDirection?: TabSplitDirection + /** Opens in this tab's slot: the next tab in its group. */ + afterTabId?: string + /** A chat's switch, whose ordinary spawn admits the resume; set only for a capable host. */ + resumeOrigin?: 'switch' + viewerSelection?: string + canActivate?: () => boolean }): LaunchAiVaultSessionInNewTabResult { const store = useAppStore.getState() + const canActivate = + args.viewerSelection === undefined + ? args.canActivate + : () => + conversationSwitchViewerSelectionCurrent(args.viewerSelection) && + args.canActivate?.() !== false + const slot = args.afterTabId + ? store.unifiedTabsByWorktree[args.worktreeId]?.find((tab) => tab.id === args.afterTabId) + : undefined + const sourceGroupId = args.targetGroupId ?? slot?.groupId + const switchStart = args.resumeOrigin ? { resumeOrigin: args.resumeOrigin } : {} // Why both paths split: the runtime path used to return first and drop the split (#22791). const split = args.splitDirection && args.targetGroupId @@ -40,12 +60,14 @@ export function launchAiVaultSessionInNewTab(args: { const splitGroupId = split ? store.createEmptySplitGroup(args.worktreeId, split.sourceGroupId, split.splitDirection) : null - const targetGroupId = splitGroupId ?? args.targetGroupId + const targetGroupId = splitGroupId ?? sourceGroupId const runtimeLaunch = createWebRuntimeSessionTerminal( { worktreeId: args.worktreeId, environmentId: runtimeEnvironmentId, ...(targetGroupId ? { targetGroupId } : {}), + ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), + ...switchStart, agentSessionKind: 'resume', launchAgent: args.agent, command: args.command, @@ -57,11 +79,17 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), activate: true }, - { notifyFailure: false } + { + notifyFailure: false, + ...(canActivate ? { canActivate } : {}), + ...(args.viewerSelection ? { viewerSelection: args.viewerSelection } : {}) + } ) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { if (outcome.status === 'created') { - useAppStore.getState().setActiveTabType('terminal', args.worktreeId) + if (canActivate?.() !== false && args.viewerSelection === undefined) { + useAppStore.getState().setActiveTabType('terminal', args.worktreeId) + } } else if (splitGroupId) { // A refused or failed drop must not leave a blank pane behind. useAppStore.getState().closeEmptyGroup(args.worktreeId, splitGroupId) @@ -71,8 +99,10 @@ export function launchAiVaultSessionInNewTab(args: { return { tabId: null, runtimeLaunch: observedRuntimeLaunch } } - const tab = store.createTab(args.worktreeId, args.targetGroupId, undefined, { + const activate = canActivate?.() !== false && args.viewerSelection === undefined + const tab = store.createTab(args.worktreeId, sourceGroupId, undefined, { ...(split ? { split } : {}), + ...(activate ? {} : { activate: false, recordInteraction: false }), ...(args.cwd ? { startupCwd: args.cwd } : {}), pendingStartup: { command: args.command, @@ -80,6 +110,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), ...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}), + ...(args.resumeOrigin ? { launchAgent: args.agent, ...switchStart } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), launch_source: 'sidebar', @@ -87,9 +118,17 @@ export function launchAiVaultSessionInNewTab(args: { } } }) - store.setActiveTabType('terminal', args.worktreeId) + if (activate) { + store.setActiveTabType('terminal', args.worktreeId) + } persistAgentLaunchTabOrder(args.worktreeId, tab.id) + if (slot && !split && (!args.targetGroupId || args.targetGroupId === slot.groupId)) { + insertUnifiedTabAfterAnchor(args.worktreeId, tab.id, slot.id) + } + if (args.viewerSelection) { + presentAgentLaunchTab('focus-window', args.worktreeId, tab.id, undefined, args.viewerSelection) + } return { tabId: tab.id } } diff --git a/src/renderer/src/lib/open-existing-structured-session.ts b/src/renderer/src/lib/open-existing-structured-session.ts index 71f70935b0e7..94adc3662dc6 100644 --- a/src/renderer/src/lib/open-existing-structured-session.ts +++ b/src/renderer/src/lib/open-existing-structured-session.ts @@ -9,6 +9,9 @@ import { refreshLocalStructuredSessionWorktreeTabs } from '@/runtime/local-struc import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner' +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import { viewerSelectionOutcome } from './viewer-selection-request' +import { conversationSwitchViewerSelectionCurrent } from './conversation-switch-selection' type StructuredSessionIdentity = { workspaceId: string; sessionId: string } @@ -31,7 +34,12 @@ export type StructuredSessionOpenDeps = { }) => Promise } -export type StructuredSessionOpenOutcome = 'activated' | 'gone' | 'host-cannot-open' | 'unavailable' +export type StructuredSessionOpenOutcome = + | 'activated' + | 'gone' + | 'host-cannot-open' + | 'unavailable' + | 'none' const defaultDeps: StructuredSessionOpenDeps = { activate: activateStructuredAgentSessionById, @@ -47,23 +55,24 @@ export async function openExistingStructuredSession( options: { target?: RuntimeClientTarget deps?: StructuredSessionOpenDeps + viewerSelection?: string present: (outcome: StructuredSessionOpenOutcome) => void onError?: () => void } ): Promise { - const { target, deps = defaultDeps, present } = options + const { target, deps = defaultDeps, present, viewerSelection } = options const hostKey = executionHostIdForStructuredTarget( target ?? runtimeTargetForOwnerEnvironment( runtimeEnvironmentIdOrLocalUnlessTwin(useAppStore.getState(), structured.workspaceId) ) ) - const key = `${hostKey}\0${structured.workspaceId}\0${structured.sessionId}` + const key = `${hostKey}\0${structured.workspaceId}\0${structured.sessionId}\0${viewerSelection ?? ''}` const inFlight = activationsInFlight.get(key) if (inFlight) { return inFlight } - const activation = activateStructuredSession(structured, deps, target).then( + const activation = activateStructuredSession(structured, deps, target, viewerSelection).then( (outcome) => { present(outcome) return outcome @@ -84,28 +93,51 @@ export async function openExistingStructuredSession( async function activateStructuredSession( structured: StructuredSessionIdentity, deps: StructuredSessionOpenDeps, - host?: RuntimeClientTarget + host?: RuntimeClientTarget, + viewerSelection?: string ): Promise { const target = { worktreeId: structured.workspaceId, sessionId: structured.sessionId, ...(host ? { target: host } : {}) } - if (!deps.activate(target)) { + const modern = viewerSelection !== undefined && isSessionTabSelectionRequest(viewerSelection) + const activate = () => { + if (modern && viewerSelectionOutcome(viewerSelection) === 'fulfilled') { + return true + } + return ( + conversationSwitchViewerSelectionCurrent(viewerSelection) && + deps.activate({ + ...target, + ...(modern ? { viewerSelection, callerOnly: true } : {}) + }) + ) + } + if (!activate()) { // Refresh can avoid reveal, but failure must fall through: host republication is the actual repair. const refreshed = await refreshedWithoutThrowing(deps, structured.workspaceId, host) - if (!refreshed || !deps.activate(target)) { + if (!refreshed || !activate()) { + if (!conversationSwitchViewerSelectionCurrent(viewerSelection)) { + return 'none' + } // Ask the host to republish a closed or unpublished tab from its persisted record. const revealed = await deps.reveal(target) if (revealed !== 'revealed') { return revealed === 'unreachable' ? 'unavailable' : revealed } await refreshedWithoutThrowing(deps, structured.workspaceId, host) - if (!deps.activate(target)) { + if (!activate()) { + if (!conversationSwitchViewerSelectionCurrent(viewerSelection)) { + return 'none' + } return 'unavailable' } } } + if (modern) { + return viewerSelectionOutcome(viewerSelection) === 'fulfilled' ? 'activated' : 'none' + } return 'activated' } diff --git a/src/renderer/src/lib/open-tab-occupant-agent.ts b/src/renderer/src/lib/open-tab-occupant-agent.ts index 002c2cc5c49c..cfac9940b87a 100644 --- a/src/renderer/src/lib/open-tab-occupant-agent.ts +++ b/src/renderer/src/lib/open-tab-occupant-agent.ts @@ -50,9 +50,8 @@ export function resolveOpenTabOccupantAgent({ resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ?? resolveSiblingRetainedTabAgent(retainedAgentsByPaneKey, layout, tabId) const focusedPaneKey = focusedPaneKeyFor(tabId, layout) - const processAgent = focusedPaneKey - ? (paneForegroundAgentByPaneKey?.[focusedPaneKey]?.agent ?? null) - : null + const process = focusedPaneKey ? paneForegroundAgentByPaneKey?.[focusedPaneKey] : undefined + const processAgent = process?.agent ?? process?.suspendedAgent ?? null const sleepingSessionAgent = focusedPaneKey ? (sleepingAgentSessionsByPaneKey[focusedPaneKey]?.agent ?? null) : null diff --git a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts index 4dfbf14f8a5b..49366e479ea9 100644 --- a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts +++ b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '../viewer-selection-request' import { useCallback, useSyncExternalStore } from 'react' import type { ClientHostedBrowserRow, @@ -91,6 +92,7 @@ export function useClientHostedBrowserRows(worktreeId: string): readonly ClientH } export function selectClientHostedBrowserRow(next: ClientHostedBrowserRowSelection): void { + noteViewerNavigation() selection = next notifyChange() } diff --git a/src/renderer/src/lib/pane-manager/pane-inline-images.ts b/src/renderer/src/lib/pane-manager/pane-inline-images.ts index 972c53ad9622..3675b0818e40 100644 --- a/src/renderer/src/lib/pane-manager/pane-inline-images.ts +++ b/src/renderer/src/lib/pane-manager/pane-inline-images.ts @@ -79,7 +79,7 @@ export function attachInlineImages(pane: ManagedPaneInternal): void { } } -export function detachInlineImages(pane: ManagedPaneInternal): void { +export function detachInlineImages(pane: ManagedPaneInternal, failures?: unknown[]): void { panesAwaitingImageAddon.delete(pane) pane.imageAttachmentDeferred = false terminalsRenderingInlineImages.delete(pane.terminal) @@ -88,7 +88,9 @@ export function detachInlineImages(pane: ManagedPaneInternal): void { if (pane.imageAddon) { try { pane.imageAddon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } pane.imageAddon = null } } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index e2e7179efaa2..68db19aadd70 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -153,7 +153,8 @@ export function setLigaturesEnabled(pane: ManagedPaneInternal, enabled: boolean) export function disposePane( pane: ManagedPaneInternal, - panes: Map + panes: Map, + failures?: unknown[] ): void { cancelPendingTerminalViewportPresents(pane.terminal) if (pane.pendingInitialFitRafId != null) { @@ -189,7 +190,9 @@ export function disposePane( // Deregister the RTL shaping joiner: terminal.dispose() below does not. try { pane.arabicShapingJoinerCleanup?.() - } catch {} + } catch (error) { + failures?.push(error) + } pane.arabicShapingJoinerCleanup = null if (pane.compositionHandler) { pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) @@ -198,39 +201,60 @@ export function disposePane( } try { clearPendingSplitScrollRestore(pane) - } catch {} + } catch (error) { + failures?.push(error) + } try { // Fit retries own markers/frames independently of split restoration; release both before terminal disposal. cancelDeferredScrollRestore(pane.terminal) - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.ligaturesAddon?.dispose() - } catch {} - // Detach pending and live image addons before terminal teardown, including their canvases/parser handlers. - detachInlineImages(pane) - disposeWebgl(pane) + } catch (error) { + failures?.push(error) + } + // Detach removes the pane from the deferred-attach set and disposes the addon + // (canvas layers + parser handlers) before the terminal surface goes away. + detachInlineImages(pane, failures) + disposeWebgl(pane, { failures }) try { pane.searchAddon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.serializeAddon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.unicode11Addon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.webLinksAddon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.fitAddon.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } try { // Drop renderer selection state before a recovery remount replaces the surface. pane.terminal.clearSelection() - } catch {} + } catch (error) { + failures?.push(error) + } try { pane.terminal.dispose() - } catch {} + } catch (error) { + failures?.push(error) + } releaseReplayGuardsForDisposedTerminal(pane.terminal) panes.delete(pane.id) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts index b3758730d596..ded990b41990 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts @@ -94,18 +94,27 @@ export function detachPaneForExternalMoveOnManager(host: PaneManagerHost, paneId }) } -export function retirePanePreservingPtyOnManager(host: PaneManagerHost, paneId: number): boolean { - return retireManagedPanePreservingPty({ - paneId, - activePaneId: host.getActivePaneId(), - panes: host.panes, - root: host.root, - styleOptions: host.getStyleOptions(), - managerOptions: host.options, - getDragCallbacks: () => host.getDragCallbacks(), - releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId), - setActivePaneId: (id) => { - host.setActivePaneId(id) - } - }) +export function retirePanePreservingPtyOnManager( + host: PaneManagerHost, + paneId: number, + permanent = false, + failures?: unknown[] +): boolean { + return retireManagedPanePreservingPty( + { + paneId, + failures, + activePaneId: host.getActivePaneId(), + panes: host.panes, + root: host.root, + styleOptions: host.getStyleOptions(), + managerOptions: host.options, + getDragCallbacks: () => host.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId), + setActivePaneId: (id) => { + host.setActivePaneId(id) + } + }, + permanent + ) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e95265d8cdc2..16c50154f55e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -31,7 +31,7 @@ export type PaneSplitOptions = Omit & { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId - reason?: 'close' | 'detach' | 'retire' + reason?: 'close' | 'detach' | 'retire' | 'remove' } export type PaneExternalDropTarget = { diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 53e43f8bef4b..9a1419b8f1f9 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -153,8 +153,8 @@ export class PaneManager { return detachPaneForExternalMoveOnManager(this.host, paneId) } - retirePanePreservingPty(paneId: number): boolean { - return retirePanePreservingPtyOnManager(this.host, paneId) + retirePanePreservingPty(paneId: number, permanent = false, failures?: unknown[]): boolean { + return retirePanePreservingPtyOnManager(this.host, paneId, permanent, failures) } getPanes(limit = Number.POSITIVE_INFINITY): ManagedPane[] { diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 62497eb91020..cc8629438b53 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -170,6 +170,7 @@ function openSplitPane( } type CloseManagedPaneArgs = { + failures?: unknown[] paneId: number activePaneId: number | null panes: Map @@ -183,7 +184,7 @@ type CloseManagedPaneArgs = { function teardownManagedPane( args: CloseManagedPaneArgs, - reason: 'close' | 'detach' | 'retire' + reason: 'close' | 'detach' | 'retire' | 'remove' ): void { const pane = args.panes.get(args.paneId) if (!pane) { @@ -194,8 +195,10 @@ function teardownManagedPane( args.activePaneId === args.paneId ? findClosedPaneSuccessor(pane.container, args.panes) : null args.releasePaneIdentity(args.paneId) removePaneContainer(args, pane) - if (reason === 'close') { - // Closed UUID leaves never recur; detach/retire transfers ownership and must preserve scroll intent. + if (reason === 'close' || reason === 'remove') { + // Leaf ids are minted UUIDs and never reused, so a closed leaf's scroll + // intent is unreachable. Detach/retire hand the leaf to a new host, which + // must still be able to restore it. releaseTerminalScrollIntentKey(closedLeafId) } const nextActivePaneId = activateReplacementPane(args, successor) @@ -209,7 +212,9 @@ function teardownManagedPane( leafId: closedLeafId, reason }) - args.managerOptions.onLayoutChanged?.() + if (reason !== 'remove') { + args.managerOptions.onLayoutChanged?.() + } } export function closeManagedPane(args: CloseManagedPaneArgs): void { @@ -225,18 +230,21 @@ export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): bo return true } -export function retireManagedPanePreservingPty(args: CloseManagedPaneArgs): boolean { - if (!args.panes.has(args.paneId) || args.panes.size <= 1) { +export function retireManagedPanePreservingPty( + args: CloseManagedPaneArgs, + permanent = false +): boolean { + if (!args.panes.has(args.paneId) || (!permanent && args.panes.size <= 1)) { return false } - teardownManagedPane(args, 'retire') + teardownManagedPane(args, permanent ? 'remove' : 'retire') return true } function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement - disposePane(pane, args.panes) + disposePane(pane, args.panes, args.failures) if (!parent) { return } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index f67c20289bb7..0b77080a6ac0 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -113,7 +113,7 @@ export function isPaneWebglContextLost(pane: ManagedPaneInternal): boolean { export function disposeWebgl( pane: ManagedPaneInternal, - options?: { refreshDimensions?: boolean } + options?: { refreshDimensions?: boolean; failures?: unknown[] } ): void { cancelPendingWebglRefresh(pane) panesAwaitingWebglAddon.delete(pane) @@ -123,7 +123,9 @@ export function disposeWebgl( releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() - } catch {} + } catch (error) { + options?.failures?.push(error) + } pane.webglAddon = null if (options?.refreshDimensions) { // Refit after teardown because DOM/WebGL cell metrics differ; otherwise Linux scrollbars desync and reflow jitters. diff --git a/src/renderer/src/lib/pending-conversation-surface-switches.ts b/src/renderer/src/lib/pending-conversation-surface-switches.ts new file mode 100644 index 000000000000..8c1e149a225d --- /dev/null +++ b/src/renderer/src/lib/pending-conversation-surface-switches.ts @@ -0,0 +1,54 @@ +import type { CliChatResumeSource } from '@/components/tab-bar/cli-chat-resume-source' +import type { AgentPaneConversationIdentity } from '@/store/slices/agent-pane-conversation' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import { getTabIdFromPaneKey } from '@/store/slices/agent-status/agent-status-pane-key-tab-binding' + +export const pendingCliChatResumes = new Map< + AbortController, + { key: string | null; source: CliChatResumeSource } +>() +export const pendingNativeChatCliSwitches = new Map< + string, + { chatTabId: string | null; workspaceId: string; executionHostId: string } +>() + +export function isCliChatResumePending(key: string): boolean { + return Array.from(pendingCliChatResumes.values()).some((operation) => operation.key === key) +} + +export function protectedConversationPaneKeys( + entries: Record +): Set { + const protectedKeys = new Set() + for (const { source } of pendingCliChatResumes.values()) { + protectedKeys.add(source.paneKey) + protectedKeys.add(resolveAgentPaneAuthorityKey(source.paneKey)) + } + const sources = [ + ...Array.from(pendingCliChatResumes.values(), ({ source }) => source), + ...pendingNativeChatCliSwitches.values() + ] + for (const [key, entry] of Object.entries(entries)) { + for (const source of sources) { + const tabId = 'tabId' in source ? source.tabId : source.chatTabId + if ( + tabId !== null && + getTabIdFromPaneKey(key) === tabId && + entry.worktreeId === source.workspaceId && + entry.executionHostId === source.executionHostId + ) { + protectedKeys.add(key) + } + } + } + return protectedKeys +} + +const listeners = new Set<() => void>() +export const conversationSwitchChanged = () => listeners.forEach((listener) => listener()) +export const subscribeConversationSwitch = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/src/renderer/src/lib/session-tab-open-placement-order.ts b/src/renderer/src/lib/session-tab-open-placement-order.ts new file mode 100644 index 000000000000..2fa50d711c05 --- /dev/null +++ b/src/renderer/src/lib/session-tab-open-placement-order.ts @@ -0,0 +1,27 @@ +import type { SessionTabOpenPlacement } from '../../../shared/session-tab-placement' +import type { Tab } from '../../../shared/tab-types' +import { + insertTabIdIntoOrder, + partitionPinnedTabOrder +} from '../../../shared/workspace-layout/tab-order' + +/** Explicit fallback index retains information absent from a coalesced inventory's neighbours. */ +export function insertOpenPlacementTab( + order: readonly string[], + tabs: readonly Tab[], + tabId: string, + placement: SessionTabOpenPlacement['placement'] +): string[] { + const tab = tabs.find((row) => row.id === tabId) + const without = order.filter((id) => id !== tabId) + if (placement.afterTabId && without.includes(placement.afterTabId)) { + return insertTabIdIntoOrder(without, tabs, tabId, tab?.isPinned === true, placement.afterTabId) + } + if (placement.index !== undefined) { + const index = Math.max(0, Math.min(Math.trunc(placement.index), without.length)) + return index === 0 + ? partitionPinnedTabOrder([...without], tabs, tabId) + : insertTabIdIntoOrder(without, tabs, tabId, tab?.isPinned === true, without[index - 1]) + } + return insertTabIdIntoOrder(without, tabs, tabId, tab?.isPinned === true) +} diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.ts b/src/renderer/src/lib/simulator-tab-palette-activation.ts index 84e1294c4fc9..fc3c0161dbb7 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from './viewer-selection-request' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorkspace } from './worktree-activation' @@ -58,6 +59,7 @@ export function activateSimulatorTabPaletteResult({ } const state = useAppStore.getState() + noteViewerNavigation() state.focusGroup(worktreeId, tab.groupId) state.activateTab(tab.id, { worktreeId }) state.setActiveTab(tab.id) diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts index 1ea8f64c6a63..2ef3764a75a8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-failure.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -20,6 +20,7 @@ export type StructuredLaunchFailure = AgentSessionWriteRefusal & { * workspace can't run the chat. */ causeText?: string owner?: AgentSessionConversationOwner + source?: true } /** A WSL distro added as a host runs chat itself, so point there once this desktop can add one. */ @@ -75,7 +76,12 @@ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure : undefined const failure = { ...agentSessionRefusalFailure(refusal), - ...(owner ? { owner } : {}) + ...(owner ? { owner } : {}), + ...((refusal.code === 'agent_session_conflict' || + refusal.code === 'agent_session_ownership_unknown') && + refusal.details?.source + ? { source: true as const } + : {}) } const message = error instanceof Error ? error.message : undefined return failure.code === 'agent_session_operation_invalid' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-surface.ts b/src/renderer/src/lib/structured-agent-session-launch-surface.ts index 416637fcf1b4..7687f1f88287 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-surface.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-surface.ts @@ -1,3 +1,4 @@ +import { selectSessionTabForViewer } from '@/runtime/web-session-tabs-sync/store-patch' import { findIndexedProjectGroupOwner, findIndexedRepoOwner, @@ -154,14 +155,20 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The tab the chat opens right after, in that tab's group. */ afterTabId?: string activate?: boolean + viewerSelection?: string + canActivate?: () => boolean }): OpenedAgentSessionTab { const state = useAppStore.getState() const existing = findStructuredLaunchTab(args) if (existing) { - if (args.activate !== false) { - state.focusGroup(args.worktreeId, existing.groupId) - state.activateTab(existing.id, { worktreeId: args.worktreeId }) - state.setActiveTabType('agent-session', args.worktreeId) + if (args.activate !== false && args.canActivate?.() !== false) { + if (args.viewerSelection) { + selectSessionTabForViewer(args.worktreeId, existing.id, args.viewerSelection) + } else { + state.focusGroup(args.worktreeId, existing.groupId) + state.activateTab(existing.id, { worktreeId: args.worktreeId }) + state.setActiveTabType('agent-session', args.worktreeId) + } } return existing } @@ -176,6 +183,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { group?.id ?? (address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId) const drawHere = (): Tab => { + const activate = args.activate !== false && args.canActivate?.() !== false const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', { id: tabId, entityId: args.sessionId, @@ -184,11 +192,13 @@ export function openStructuredAgentSessionProvisionalTab(args: { label: defaultAgentChatLabel(args.agent), targetGroupId, ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), - activate: args.activate !== false + activate: activate && args.viewerSelection === undefined }) const target = structuredAgentSessionTargetForHost(args.executionHostId) holdFrameDrawnProvisionalChatTab(target, args.worktreeId, tab.id) - if (args.activate !== false) { + if (activate && args.viewerSelection) { + selectSessionTabForViewer(args.worktreeId, tab.id, args.viewerSelection) + } else if (activate) { state.setActiveTabType('agent-session', args.worktreeId) } return tab @@ -206,6 +216,8 @@ export function openStructuredAgentSessionProvisionalTab(args: { ? { afterTabId: args.afterTabId } : slotAnchor(address, args.worktreeId)), activate: args.activate !== false, + viewerSelection: args.viewerSelection, + canActivate: args.canActivate, drawHere }) : null diff --git a/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts index 0375143af756..4d0a7ef4cfb7 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts @@ -1,3 +1,4 @@ +import { selectSessionTabForViewer } from '@/runtime/web-session-tabs-sync/store-patch' // Send open with the chat’s own tab ID, select only after main draws it, and use afterPendingTab before reading the returned tab from store. import type { ExecutionHostId } from '../../../shared/execution-host' @@ -18,6 +19,8 @@ export type AgentSessionTabOpen = { targetGroupId?: string afterTabId?: string activate: boolean + viewerSelection?: string + canActivate?: () => boolean /** Today's draw, for a twin main may route to a paired server. */ drawHere: () => Tab } @@ -52,7 +55,11 @@ export function openAgentSessionTabThroughLayoutOwner( command: { type: 'openAgentSessionTab', tabId, sessionId, agent: open.agent, ...placement }, facts: { label }, shown: (shownId) => { - if (!open.activate) { + if (!open.activate || open.canActivate?.() === false) { + return + } + if (open.viewerSelection) { + selectSessionTabForViewer(worktreeId, shownId, open.viewerSelection) return } // As the window's own open: the chat is selected in its group; the focused group stays. diff --git a/src/renderer/src/lib/tab-number-shortcuts.ts b/src/renderer/src/lib/tab-number-shortcuts.ts index d28334741fb5..b9ba6c783ac5 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from './viewer-selection-request' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -57,6 +58,7 @@ export function activateTabNumberShortcut(index: number): boolean { return false } + noteViewerNavigation() const worktreeId = target.worktreeId const runtimeEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin(store, worktreeId) store.focusGroup(worktreeId, target.groupId) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 221d906a9290..f452c0b5fda8 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -60,11 +60,18 @@ export function useTabAgent(tab: TerminalTab): TerminalAgent | null { return activeLeafId && isTerminalLeafId(activeLeafId) ? makePaneKey(tab.id, activeLeafId) : null }) const processAgent = useAppStore((s) => - focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.agent ?? null) : null + focusedPaneKey + ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.agent ?? + s.paneForegroundAgentByPaneKey[focusedPaneKey]?.suspendedAgent ?? + null) + : null ) const processShellForeground = useAppStore((s) => focusedPaneKey - ? Boolean(s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground) + ? Boolean( + s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground && + !s.paneForegroundAgentByPaneKey[focusedPaneKey]?.suspendedAgent + ) : false ) // Why: a hibernated pane's session record is the freshest identity once PTY, hook, and process signals are all gone. diff --git a/src/renderer/src/lib/viewer-selection-request.ts b/src/renderer/src/lib/viewer-selection-request.ts new file mode 100644 index 000000000000..43cc15d9f991 --- /dev/null +++ b/src/renderer/src/lib/viewer-selection-request.ts @@ -0,0 +1,154 @@ +import { isSessionTabSelectionRequest } from '../../../shared/session-tab-placement' +import type { AgentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { createBrowserUuid } from './browser-uuid' + +type Destination = { worktreeId: string; tabId: string } +type Request = { + token: string + order: number + revision: number + deadline: Pick + outcome: 'pending' | 'fulfilled' | 'superseded' + destination?: Destination +} + +let lifetime = createBrowserUuid() +let navigationRevision = 0 +let actionOrder = 0 +let acceptedOrder = 0 +let current: Request | undefined + +/** Explicit navigation only; automatic layout repair/publication never calls this ingress. */ +export function noteViewerNavigation(): void { + navigationRevision += 1 + if (current) { + current.outcome = 'superseded' + } +} + +/** Capture before preparation/consent; accepting an older dialog cannot replace a newer action. */ +export function beginViewerSelectionRequest( + invocationId: string, + deadline: Pick +) { + const candidate: Request = { + token: `selection-v1:${lifetime}:${invocationId}:${++actionOrder}`, + order: actionOrder, + revision: navigationRevision, + deadline, + outcome: 'pending' + } + return { + token: candidate.token, + accept: () => { + if (candidate.order <= acceptedOrder) { + candidate.outcome = 'superseded' + return + } + acceptedOrder = candidate.order + if (current) { + current.outcome = 'superseded' + } + current = candidate + if (candidate.revision !== navigationRevision) { + candidate.outcome = 'superseded' + } + }, + discard: () => { + candidate.outcome = 'superseded' + } + } +} + +export function viewerSelectionOutcome(token: string): 'pending' | 'fulfilled' | 'superseded' { + if (!isSessionTabSelectionRequest(token) || current?.token !== token) { + return 'superseded' + } + if (current.outcome === 'pending') { + try { + current.deadline.check() + } catch { + current.outcome = 'superseded' + } + if (current.revision !== navigationRevision) { + current.outcome = 'superseded' + } + } + return current.outcome +} + +/** Called inside the accepted ordinary writer after resolving membership, before its subscribers. */ +export function commitViewerSelection( + token: string, + destination: Destination, + apply: () => boolean +): boolean { + if (viewerSelectionOutcome(token) !== 'pending' || !current) { + return false + } + const request = current + if ( + request.destination && + (request.destination.worktreeId !== destination.worktreeId || + request.destination.tabId !== destination.tabId) + ) { + return false + } + request.destination = destination + request.outcome = 'fulfilled' + try { + if (apply()) { + return true + } + } catch (error) { + if (current === request) { + request.outcome = 'pending' + } + throw error + } + // Missing membership is not success; no state was applied. + if (current === request && request.outcome === 'fulfilled') { + request.outcome = 'pending' + } + return false +} + +/** The ordinary publication owner knows the destination before it can be drawn. */ +export function bindViewerSelectionDestination( + token: string | undefined, + destination: Destination +): void { + if (token && viewerSelectionOutcome(token) === 'pending' && current) { + current.destination ??= destination + } +} + +/** A definite close/withdrawal cannot leave an undrawn destination's selection armed. */ +export function endViewerSelectionForDestination(worktreeId: string, tabId: string): void { + if (current?.destination?.worktreeId === worktreeId && current.destination.tabId === tabId) { + current.outcome = 'superseded' + } +} + +/** Pointer focus on another pane is navigation; typing/copying in the current pane is not. */ +export function noteViewerGroupNavigation( + state: { + activeWorktreeId: string | null + activeGroupIdByWorktree: Record + }, + worktreeId: string, + groupId: string +): void { + if ( + state.activeWorktreeId !== worktreeId || + state.activeGroupIdByWorktree[worktreeId] !== groupId + ) { + noteViewerNavigation() + } +} + +export function disposeViewerSelection(): void { + current = undefined + lifetime = createBrowserUuid() + navigationRevision = actionOrder = acceptedOrder = 0 +} diff --git a/src/renderer/src/lib/workspace-switch-user-act.ts b/src/renderer/src/lib/workspace-switch-user-act.ts index cf832e29033c..bc8f3f237803 100644 --- a/src/renderer/src/lib/workspace-switch-user-act.ts +++ b/src/renderer/src/lib/workspace-switch-user-act.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from './viewer-selection-request' /** * The user's own switches to a workspace (opening it, picking one of its tabs) in this window. Only * these may wake or start a stopped WSL distro (E9); launch restore, a reload, remounts and Orca's own @@ -21,6 +22,7 @@ export function noteUserWorkspaceSwitch(workspaceKey: string | null | undefined) if (!workspaceKey) { return } + noteViewerNavigation() lastSwitchAt.set(workspaceKey, Date.now()) switchCounts.set(workspaceKey, (switchCounts.get(workspaceKey) ?? 0) + 1) } diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 43ca1bccb5ac..387e0c2dae3d 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from './viewer-selection-request' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { activateWebRuntimeSessionTab } from '@/runtime/web-runtime-session-tab-lifecycle' @@ -104,6 +105,7 @@ export function activateWorkspaceTabPaletteResult( return { status: 'failed', reason: initialFailure } } + noteViewerNavigation() const executionHostId = result.executionHostId ?? initialState.getKnownWorktreeById(result.worktreeId)?.hostId const activated = executionHostId diff --git a/src/renderer/src/lib/worktree-activation-pty-inventory.ts b/src/renderer/src/lib/worktree-activation-pty-inventory.ts index 2018f8578a16..5fa974dc9340 100644 --- a/src/renderer/src/lib/worktree-activation-pty-inventory.ts +++ b/src/renderer/src/lib/worktree-activation-pty-inventory.ts @@ -12,6 +12,14 @@ import { type WorktreeOperationRouteState } from './worktree-operation-route' +export function canInspectAgentActivationInventory(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.api?.runtime?.call === 'function' && + typeof window.api?.pty?.listSessions === 'function' + ) +} + /** Only a reachable execution-owner route can authorize activation from its inventory. */ export function resolveActivationPtyListScope( state: WorktreeOperationRouteState, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 9b2f969c5630..390694f87bc2 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,3 +1,4 @@ +import { canInspectAgentActivationInventory } from './worktree-activation-pty-inventory' import { noteUserWorkspaceOpen } from './workspace-switch-user-act' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' @@ -54,14 +55,6 @@ export type ActivateAndRevealResult = { primaryTabId: string | null } -function canInspectAgentActivationInventory(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.api?.runtime?.call === 'function' && - typeof window.api?.pty?.listSessions === 'function' - ) -} - export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: WorktreeActivationSurfaceSelection & { @@ -73,6 +66,7 @@ export function activateAndRevealFolderWorkspace( executionHostId?: ExecutionHostId } ): ActivateAndRevealResult | false { + noteUserWorkspaceOpen(folderWorkspaceKey(folderWorkspaceId), opts?.navigationIntent) const state = useAppStore.getState() const folderWorkspaceOwner = findFolderWorkspaceOwner( state, @@ -176,6 +170,7 @@ export function activateAndRevealWorktree( worktreeId: string, opts?: WorktreeActivationOptions ): ActivateAndRevealResult | false { + noteUserWorkspaceOpen(worktreeId, opts?.navigationIntent) const state = useAppStore.getState() // The floating workspace owns a separate panel, never the main window's selection. if (isFloatingWorkspaceId(worktreeId)) { @@ -210,7 +205,6 @@ export function activateAndRevealWorktree( if (state.activeView !== 'terminal') { state.setActiveView('terminal') } - noteUserWorkspaceOpen(worktreeId, opts?.navigationIntent) state.setActiveWorktree( worktreeId, diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts index 31e463b97121..104efa00f6ae 100644 --- a/src/renderer/src/runtime/abortable-runtime-environment-call.ts +++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts @@ -1,5 +1,8 @@ import { createBrowserUuid } from '@/lib/browser-uuid' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../../../shared/runtime-rpc-envelope' export function createRuntimeRpcAbortError(): Error { const error = new Error('Runtime request aborted') @@ -14,7 +17,9 @@ export async function callAbortableRuntimeEnvironment( timeoutMs: number | undefined, signal: AbortSignal, expectedEnvironmentPairingRevision?: number, - expectedEnvironmentRuntimeId?: string + expectedEnvironmentRuntimeId?: string, + beforeSend?: (sentAt?: number) => void, + dispatchObservation?: RuntimeRpcDispatchObservation ): Promise> { if (signal.aborted) { throw createRuntimeRpcAbortError() @@ -24,13 +29,26 @@ export async function callAbortableRuntimeEnvironment( const subscriptionId = createBrowserUuid() let handle: { unsubscribe: () => void } | null = null let settled = false + let sent = false // Subscription timeout bounds start only; retain the response deadline or connected unresponsive hosts stall forever. - const deadline = + let deadline = timeoutMs === undefined ? null - : setTimeout(() => { - finish(() => reject(new Error(`Runtime request timed out before ${method} completed`))) - }, timeoutMs) + : setTimeout( + () => { + finish(() => + reject(new Error(`Runtime request timed out before ${method} completed`)) + ) + }, + dispatchObservation + ? Math.max( + 0, + (dispatchObservation.responseExpiresAt ?? + dispatchObservation.expiresAt + dispatchObservation.responseTimeoutMs) - + Date.now() + ) + : timeoutMs + ) const finish = (complete: () => void): void => { if (settled) { return @@ -59,9 +77,38 @@ export async function callAbortableRuntimeEnvironment( params, timeoutMs, expectedEnvironmentPairingRevision, - expectedEnvironmentRuntimeId + expectedEnvironmentRuntimeId, + observeDispatch: Boolean(beforeSend), + dispatchObservation }, { + onSent: (sentAt) => { + if (sent || settled) { + return + } + sent = true + try { + beforeSend?.(sentAt) + } catch (error) { + finish(() => reject(error)) + return + } + if (deadline !== null) { + clearTimeout(deadline) + } + if (timeoutMs !== undefined) { + deadline = setTimeout( + () => finish(() => reject(new Error('Request timed out'))), + dispatchObservation + ? Math.max( + 0, + (dispatchObservation.responseExpiresAt ?? + (sentAt ?? Date.now()) + dispatchObservation.responseTimeoutMs) - Date.now() + ) + : timeoutMs + ) + } + }, onResponse: (response) => finish(() => resolve(response)), onError: (error) => finish(() => reject(new Error(error.message))), onClose: () => diff --git a/src/renderer/src/runtime/divider-ratio-refusal.test.ts b/src/renderer/src/runtime/divider-ratio-refusal.test.ts index c1f8caf2dfba..c9e1126cb86b 100644 --- a/src/renderer/src/runtime/divider-ratio-refusal.test.ts +++ b/src/renderer/src/runtime/divider-ratio-refusal.test.ts @@ -18,7 +18,7 @@ import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) // The test opens its own stream: boot's document-long one would outlive the test's transport. -vi.mock('./workspace-layout-frame-sync', async (importOriginal) => ({ +vi.mock('./workspace-layout-frame-startup', async (importOriginal) => ({ ...(await importOriginal()), showStartupLayoutFrames: () => {} })) diff --git a/src/renderer/src/runtime/layout-changes-during-stream-outage.test.ts b/src/renderer/src/runtime/layout-changes-during-stream-outage.test.ts index 3bf9d2a503b9..9c7c0e5bf043 100644 --- a/src/renderer/src/runtime/layout-changes-during-stream-outage.test.ts +++ b/src/renderer/src/runtime/layout-changes-during-stream-outage.test.ts @@ -38,7 +38,7 @@ import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) // The test opens its own stream: boot's document-long one would outlive the test's double. -vi.mock('./workspace-layout-frame-sync', async (importOriginal) => ({ +vi.mock('./workspace-layout-frame-startup', async (importOriginal) => ({ ...(await importOriginal()), showStartupLayoutFrames: () => {} })) diff --git a/src/renderer/src/runtime/layout-frame-stream.ts b/src/renderer/src/runtime/layout-frame-stream.ts index 76320e1b1880..437db9d053db 100644 --- a/src/renderer/src/runtime/layout-frame-stream.ts +++ b/src/renderer/src/runtime/layout-frame-stream.ts @@ -17,7 +17,7 @@ import { framesDrawWorkspace } from './workspace-layout-frame-drawn-workspaces' /** Whose layout stream a sync shows, and which of the window's workspaces it draws. */ export type LayoutFrameSource = { target: RuntimeClientTarget - draws: (state: AppState) => (key: string) => boolean + draws: (state: AppState, openedWorkspaces?: ReadonlySet) => (key: string) => boolean /** Tabs this window draws that the source's frames never carry: every build keeps them as drawn. */ heldTabs?: HeldDrawnTabs /** Frames under this window's ids; the local stream's already are. */ diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 400e2893e331..92e7e4f51198 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -7,7 +7,10 @@ import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner import { getExecutionHostIdForWorktree } from '../../lib/worktree-runtime-owner' import { applyWebSessionTabsSnapshot } from '../web-session-tabs-sync/snapshot-api' import { applyWebSessionTabsStorePatch } from '../web-session-tabs-sync/store-patch' -import type { WebSessionTabsSyncState } from '../web-session-tabs-sync/state' +import type { + SnapshotOpenSelectionOffer, + WebSessionTabsSyncState +} from '../web-session-tabs-sync/state' import { admitRetiredValueUnderAuthority, noteRetiredValue, @@ -46,6 +49,7 @@ function isWorktreeRetraction( } export type StructuredSessionSnapshotApplyOptions = { + offerOpenSelection?: (offer: SnapshotOpenSelectionOffer) => void /** Authority census replies answer a just-issued listAll, so exempt them from retired-epoch fencing of delayed dead-generation stream frames. */ authoritative?: boolean /** Re-applies a publication at the version already applied: the window removed a chat early. */ @@ -70,7 +74,7 @@ export function applyStructuredSessionTabSnapshots( const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] const drawnChatLabels: DrawnChatLabel[] = [] const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( - (state, changedAgentPaneKeys) => + (state, changedAgentPaneKeys, offerOpenSelection) => applyLocalStructuredSessionTabSnapshots( state, snapshots, @@ -78,6 +82,7 @@ export function applyStructuredSessionTabSnapshots( undefined, { ...options, + offerOpenSelection, onAcceptedAgentSession: (worktreeId, sessionId) => { acceptedAgentSessions.push({ worktreeId, sessionId }) options.onAcceptedAgentSession?.(worktreeId, sessionId) @@ -88,7 +93,8 @@ export function applyStructuredSessionTabSnapshots( }, changedAgentPaneKeys ), - { frames: [] } + { frames: [] }, + snapshots ) settleStructuredSessionMirror() const { setTabLabel } = useAppStore.getState() @@ -174,6 +180,9 @@ export function applyLocalStructuredSessionTabSnapshots< owner, now, { + offerOpenSelection: options.framesDraw?.(snapshot.worktree) + ? undefined + : options.offerOpenSelection, contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' diff --git a/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts index c94d3f1ffbfc..efef1cfb4e9c 100644 --- a/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts +++ b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts @@ -14,7 +14,6 @@ import { indexPublishedLayout } from '../../../shared/workspace-layout/workspace-layout-records' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' - /** The model main loads from one stored partition, with the ids it mints at load. */ export function loadMainLayoutModel( hostId: ExecutionHostId, diff --git a/src/renderer/src/runtime/mounted-terminal-tab-registry.ts b/src/renderer/src/runtime/mounted-terminal-tab-registry.ts index 727f68be9c8e..536fa73d2aea 100644 --- a/src/renderer/src/runtime/mounted-terminal-tab-registry.ts +++ b/src/renderer/src/runtime/mounted-terminal-tab-registry.ts @@ -5,7 +5,10 @@ import { import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' +import type { TerminalSurfaceCloseIdentity } from '../../../shared/terminal-tab-close' + export type RegisteredTerminalTab = { + retirePresentation?: (original: TerminalSurfaceCloseIdentity) => void tabId: string worktreeId: string getManager: () => PaneManager | null diff --git a/src/renderer/src/runtime/open-placement-work-counter.test-fixture.ts b/src/renderer/src/runtime/open-placement-work-counter.test-fixture.ts new file mode 100644 index 000000000000..b0fa1f258e2c --- /dev/null +++ b/src/renderer/src/runtime/open-placement-work-counter.test-fixture.ts @@ -0,0 +1,192 @@ +import { vi } from 'vitest' +import * as placementReads from './web-session-tabs-sync/open-placement-read' +import * as snapshotPlacements from './web-session-tabs-sync/open-placement' +import * as frameSelections from './workspace-layout-frame-open-selection' +import * as mappings from './web-session-tabs-sync/tracking-mappings' +import { hostSessionTabIdByLocalKey } from './web-session-tabs-sync/state' +import * as selectionFingerprint from '../../../shared/structured-agent-session-mutation' + +/** Count metadata work while delegating every read and final writer to production. */ +export function countOpenPlacementWork() { + type Pass = 'snapshot' | 'discovery' | 'writer' + const reads: { + pass: Pass + worktreeId: string + tabRows: number + terminalRows: number + snapshotRows: number + mappingCopies: number + mappingRows: number + sourceChecks: number + viewerChecks: number + destinationLookups: number + }[] = [] + const visits: { pass: Pass; worktreeId: string; rows: number }[] = [] + const builtKeys: string[][] = [] + let pass: Pass = 'snapshot' + let indexing: (typeof reads)[number] | undefined + let fingerprints = 0 + const restores: (() => void)[] = [] + const countedRows = (rows: T[], visit: () => void): T[] => + new Proxy(rows, { + get(target, property, receiver) { + if (typeof property === 'string' && /^(0|[1-9]\d*)$/.test(property)) { + visit() + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. + return Reflect.get(target, property, receiver) + } + }) + const countLayout = (layout: T): T => { + const visit = { pass, worktreeId: layout.worktreeId, rows: 0 } + visits.push(visit) + return { + ...layout, + tabs: countedRows(layout.tabs, () => { + if (!indexing) { + visit.rows += 1 + } + }) + } + } + const realMappingGet = hostSessionTabIdByLocalKey.get.bind(hostSessionTabIdByLocalKey) + const mappingGet = vi.spyOn(hostSessionTabIdByLocalKey, 'get').mockImplementation((key) => { + if (indexing) { + indexing.mappingCopies += 1 + } + return realMappingGet(key) + }) + restores.push(() => mappingGet.mockRestore()) + const realMappings = mappings.hostSessionTabIdsByLocalTabForWorktree + const mappingRead = vi + .spyOn(mappings, 'hostSessionTabIdsByLocalTabForWorktree') + .mockImplementation((...args) => { + const rows = realMappings(...args) + const reading = indexing + if (reading) { + const iterate = rows[Symbol.iterator].bind(rows) + rows[Symbol.iterator] = function* () { + for (const entry of iterate()) { + reading.mappingRows += 1 + yield entry + } + return undefined + } + } + return rows + }) + restores.push(() => mappingRead.mockRestore()) + const realRead = placementReads.createOpenPlacementRead + const read = vi + .spyOn(placementReads, 'createOpenPlacementRead') + .mockImplementation((state, worktreeId, owner, snapshot, now) => { + const counts = { + pass, + worktreeId, + tabRows: 0, + terminalRows: 0, + snapshotRows: 0, + mappingCopies: 0, + mappingRows: 0, + sourceChecks: 0, + viewerChecks: 0, + destinationLookups: 0 + } + reads.push(counts) + const before = indexing + indexing = counts + let actual: ReturnType + try { + actual = realRead( + { + ...state, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: countedRows( + state.unifiedTabsByWorktree[worktreeId] ?? [], + () => counts.tabRows++ + ) + }, + tabsByWorktree: { + ...state.tabsByWorktree, + [worktreeId]: countedRows( + state.tabsByWorktree[worktreeId] ?? [], + () => counts.terminalRows++ + ) + } + }, + worktreeId, + owner, + snapshot && { + ...snapshot, + tabs: countedRows(snapshot.tabs, () => counts.snapshotRows++) + }, + now + ) + } finally { + indexing = before + } + return { + ...actual, + sourceCurrent: (...args) => { + counts.sourceChecks++ + return actual.sourceCurrent(...args) + }, + viewerSelectionCurrent: (...args) => { + counts.viewerChecks++ + return actual.viewerSelectionCurrent(...args) + }, + localTabId: (...args) => { + counts.destinationLookups++ + return actual.localTabId(...args) + } + } + }) + restores.push(() => read.mockRestore()) + const realSnapshot = snapshotPlacements.prepareSnapshotOpenPlacements + const snapshot = vi + .spyOn(snapshotPlacements, 'prepareSnapshotOpenPlacements') + .mockImplementation((state, layout, ...rest) => { + pass = 'snapshot' + return realSnapshot(state, countLayout({ ...layout, worktreeId: layout.worktree }), ...rest) + }) + restores.push(() => snapshot.mockRestore()) + const realDiscovery = frameSelections.frameOpenPlacements + const discovery = vi + .spyOn(frameSelections, 'frameOpenPlacements') + .mockImplementation((state, layout, owner) => { + pass = 'discovery' + return realDiscovery(state, countLayout(layout), owner) + }) + restores.push(() => discovery.mockRestore()) + const realWriter = frameSelections.frameOpenSelectionPatch + const writer = vi + .spyOn(frameSelections, 'frameOpenSelectionPatch') + .mockImplementation((before, patch, cache, keys, ...rest) => { + pass = 'writer' + builtKeys.push([...keys]) + const countedCache = { ...cache } + for (const key of keys) { + if (cache[key]) { + countedCache[key] = countLayout(cache[key]) + } + } + return realWriter(before, patch, countedCache, keys, ...rest) + }) + restores.push(() => writer.mockRestore()) + const realFingerprint = selectionFingerprint.structuredAgentSessionDomainFingerprint + const fingerprint = vi + .spyOn(selectionFingerprint, 'structuredAgentSessionDomainFingerprint') + .mockImplementation((...args) => { + fingerprints++ + return realFingerprint(...args) + }) + restores.push(() => fingerprint.mockRestore()) + return { + reads, + visits, + builtKeys, + fingerprints: () => fingerprints, + stop: () => restores.toReversed().forEach((restore) => restore()) + } +} diff --git a/src/renderer/src/runtime/pane-titles-drawn-from-frames.test.ts b/src/renderer/src/runtime/pane-titles-drawn-from-frames.test.ts index b958e0b32127..34e4c787172c 100644 --- a/src/renderer/src/runtime/pane-titles-drawn-from-frames.test.ts +++ b/src/renderer/src/runtime/pane-titles-drawn-from-frames.test.ts @@ -26,7 +26,7 @@ vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => ).runtimeLayoutClientWithTestFrames(await importOriginal()) ) // The test opens its own stream: boot's document-long one would outlive the test's transport. -vi.mock('./workspace-layout-frame-sync', async (importOriginal) => ({ +vi.mock('./workspace-layout-frame-startup', async (importOriginal) => ({ ...(await importOriginal()), showStartupLayoutFrames: () => {} })) diff --git a/src/renderer/src/runtime/runtime-environment-compatibility-cache.ts b/src/renderer/src/runtime/runtime-environment-compatibility-cache.ts new file mode 100644 index 000000000000..384138cf00cf --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-compatibility-cache.ts @@ -0,0 +1,51 @@ +import type { RuntimeStatus } from '../../../shared/runtime-types' + +export const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 + +export const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 + +// Why: capability verdicts must eventually follow a saved environment's version changes. +export const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 + +export type RuntimeCompatibilityCacheEntry = { + check: Promise + failedAt: number | null + // False while probing so recovery can drop a doomed pending compatibility check. + provenCompatible: boolean + status: RuntimeStatus | null + statusCheckedAt: number | null +} + +export const runtimeCompatibilityChecks = new Map() + +/** A pending probe by default; an observed status is proof of itself. */ +export function compatibilityEntry( + status: RuntimeStatus | null = null, + provenCompatible = status !== null +): RuntimeCompatibilityCacheEntry { + const statusCheckedAt = status ? Date.now() : null + return { check: Promise.resolve(), failedAt: null, provenCompatible, status, statusCheckedAt } +} + +export function getCachedRuntimeCompatibilityCheck( + environmentId: string, + options: { reuseRecentCompatibilityFailure?: boolean } +): RuntimeCompatibilityCacheEntry | null { + const cached = runtimeCompatibilityChecks.get(environmentId) + if (!cached) { + return null + } + if ( + cached.failedAt !== null && + Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS + ) { + runtimeCompatibilityChecks.delete(environmentId) + return null + } + if (cached.failedAt !== null && options.reuseRecentCompatibilityFailure !== true) { + return null + } + runtimeCompatibilityChecks.delete(environmentId) + runtimeCompatibilityChecks.set(environmentId, cached) + return cached +} diff --git a/src/renderer/src/runtime/runtime-rpc-client-capabilities.test.ts b/src/renderer/src/runtime/runtime-rpc-client-capabilities.test.ts new file mode 100644 index 000000000000..cbffcf99dfa1 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client-capabilities.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + clearRecentRuntimeCompatibilityFailure, + runtimeEnvironmentSupportsCapability +} from './runtime-rpc-client' +import { + installRuntimeRpcClientTestBridge, + runtimeEnvironmentCall +} from './runtime-rpc-client-test-bridge' + +installRuntimeRpcClientTestBridge() + +describe('runtime RPC capability compatibility', () => { + it('checks advertised runtime capabilities after protocol compatibility', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['project-host-setup.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).resolves.toBeUndefined() + }) + + it('re-probes capability support after a failed compatibility cache entry', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-cap-recover' } as const + + await expect(callRuntimeRpc(target, 'repo.list')).rejects.toThrow('offline') + await expect( + runtimeEnvironmentSupportsCapability('env-cap-recover', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(true) + expect(statusCalls).toBe(2) + }) + + it('re-probes a missing capability on retry so a runtime upgrade can recover', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? [] : ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(false) + await expect( + runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(true) + expect(statusCalls).toBe(2) + }) + + it('dispatches capability-selected legacy without a redundant status probe', async () => { + const methods: string[] = [] + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + methods.push(method) + if (method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'old-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { terminal: { handle: 'legacy' } }, + _meta: { runtimeId: 'old-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-legacy' } as const + + await expect( + runtimeEnvironmentSupportsCapability('env-legacy', 'agent-session.host-authority.v1') + ).resolves.toBe(false) + await callRuntimeRpc(target, 'terminal.create', {}, { skipCompatibilityCheck: true }) + + expect(methods).toEqual(['status.get', 'terminal.create']) + }) + + it('coalesces concurrent cold-cache capability probes onto one status.get', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + const [a, b, c] = await Promise.all([ + runtimeEnvironmentSupportsCapability( + 'env-cap-concurrent', + 'linear.issue-attribute-filter.v1' + ), + runtimeEnvironmentSupportsCapability( + 'env-cap-concurrent', + 'linear.issue-attribute-filter.v1' + ), + runtimeEnvironmentSupportsCapability('env-cap-concurrent', 'linear.issue-attribute-filter.v1') + ]) + + expect([a, b, c]).toEqual([true, true, true]) + expect(statusCalls).toBe(1) + }) + + it('expires a supported capability verdict so a runtime downgrade is detected', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + try { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? ['linear.issue-attribute-filter.v1'] : [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(true) + vi.setSystemTime(new Date(59_999)) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(true) + vi.setSystemTime(new Date(60_000)) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(false) + expect(statusCalls).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('invalidates a positive capability verdict when the endpoint runtime changes', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + const runtimeId = statusCalls === 1 ? 'runtime-before-restart' : 'runtime-after-restart' + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId, + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? ['agent-session.host-authority.v1'] : [] + }, + _meta: { runtimeId } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability( + 'env-runtime-replaced', + 'agent-session.host-authority.v1' + ) + ).resolves.toBe(true) + clearRecentRuntimeCompatibilityFailure('env-runtime-replaced', { + runtimeId: 'runtime-after-restart', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } as RuntimeStatus) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-runtime-replaced', + 'agent-session.host-authority.v1' + ) + ).resolves.toBe(false) + expect(statusCalls).toBe(2) + }) + + it('rejects missing advertised runtime capabilities with the caller message', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).rejects.toThrow('Project setup is unavailable.') + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client-test-bridge.ts b/src/renderer/src/runtime/runtime-rpc-client-test-bridge.ts new file mode 100644 index 000000000000..569c40913b02 --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-client-test-bridge.ts @@ -0,0 +1,26 @@ +import { beforeEach, vi } from 'vitest' +import type { Mock } from 'vitest' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +export const runtimeCall: Mock = vi.fn() +export const runtimeEnvironmentCall: Mock = vi.fn() +export const runtimeEnvironmentSubscribe: Mock = vi.fn() + +export function installRuntimeRpcClientTestBridge(): void { + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() + vi.stubGlobal('window', { + api: { + runtime: { call: runtimeCall }, + runtimeEnvironments: { + call: runtimeEnvironmentCall, + cancelSubscription: vi.fn().mockResolvedValue(undefined), + subscribe: runtimeEnvironmentSubscribe + } + } + }) + }) +} diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 342f6547986b..72afe7578b4a 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -1,12 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeStatus } from '../../../shared/runtime-types' -import { - callRuntimeRpc, - assertRuntimeEnvironmentCapability, - clearRecentRuntimeCompatibilityFailure, - clearRuntimeCompatibilityCacheForTests, - runtimeEnvironmentSupportsCapability -} from './runtime-rpc-client' +import { describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc, clearRecentRuntimeCompatibilityFailure } from './runtime-rpc-client' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-result' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -17,25 +10,14 @@ import { ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY } from '../../../shared/runtime-rpc-feature-interaction-source' -const runtimeCall = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentSubscribe = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - runtimeCall.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentSubscribe.mockReset() - vi.stubGlobal('window', { - api: { - runtime: { call: runtimeCall }, - runtimeEnvironments: { - call: runtimeEnvironmentCall, - subscribe: runtimeEnvironmentSubscribe - } - } - }) -}) +import { + installRuntimeRpcClientTestBridge, + runtimeCall, + runtimeEnvironmentCall, + runtimeEnvironmentSubscribe +} from './runtime-rpc-client-test-bridge' + +installRuntimeRpcClientTestBridge() describe('runtime RPC client routing', () => { it('routes local runtime calls through window.api.runtime.call', async () => { @@ -465,277 +447,6 @@ describe('runtime RPC client routing', () => { ]) }) - it('checks advertised runtime capabilities after protocol compatibility', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: ['project-host-setup.v1'] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - - await expect( - assertRuntimeEnvironmentCapability( - 'env-1', - 'project-host-setup.v1', - 'Project setup is unavailable.' - ) - ).resolves.toBeUndefined() - }) - - it('re-probes capability support after a failed compatibility cache entry', async () => { - let statusCalls = 0 - runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { - if (method === 'status.get') { - statusCalls += 1 - if (statusCalls === 1) { - return Promise.resolve({ - id: 'status', - ok: false, - error: { code: 'runtime_unavailable', message: 'offline' }, - _meta: { runtimeId: 'remote-runtime' } - }) - } - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: ['linear.issue-attribute-filter.v1'] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - } - return Promise.resolve({ - id: method, - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - }) - const target = { kind: 'environment', environmentId: 'env-cap-recover' } as const - - await expect(callRuntimeRpc(target, 'repo.list')).rejects.toThrow('offline') - await expect( - runtimeEnvironmentSupportsCapability('env-cap-recover', 'linear.issue-attribute-filter.v1') - ).resolves.toBe(true) - expect(statusCalls).toBe(2) - }) - - it('re-probes a missing capability on retry so a runtime upgrade can recover', async () => { - let statusCalls = 0 - runtimeEnvironmentCall.mockImplementation(() => { - statusCalls += 1 - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: statusCalls === 1 ? [] : ['linear.issue-attribute-filter.v1'] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - }) - - await expect( - runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') - ).resolves.toBe(false) - await expect( - runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') - ).resolves.toBe(true) - expect(statusCalls).toBe(2) - }) - - it('dispatches capability-selected legacy without a redundant status probe', async () => { - const methods: string[] = [] - runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { - methods.push(method) - if (method === 'status.get') { - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'old-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: [] - }, - _meta: { runtimeId: 'old-runtime' } - }) - } - return Promise.resolve({ - id: method, - ok: true, - result: { terminal: { handle: 'legacy' } }, - _meta: { runtimeId: 'old-runtime' } - }) - }) - const target = { kind: 'environment', environmentId: 'env-legacy' } as const - - await expect( - runtimeEnvironmentSupportsCapability('env-legacy', 'agent-session.host-authority.v1') - ).resolves.toBe(false) - await callRuntimeRpc(target, 'terminal.create', {}, { skipCompatibilityCheck: true }) - - expect(methods).toEqual(['status.get', 'terminal.create']) - }) - - it('coalesces concurrent cold-cache capability probes onto one status.get', async () => { - let statusCalls = 0 - runtimeEnvironmentCall.mockImplementation(() => { - statusCalls += 1 - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: ['linear.issue-attribute-filter.v1'] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - }) - - const [a, b, c] = await Promise.all([ - runtimeEnvironmentSupportsCapability( - 'env-cap-concurrent', - 'linear.issue-attribute-filter.v1' - ), - runtimeEnvironmentSupportsCapability( - 'env-cap-concurrent', - 'linear.issue-attribute-filter.v1' - ), - runtimeEnvironmentSupportsCapability('env-cap-concurrent', 'linear.issue-attribute-filter.v1') - ]) - - expect([a, b, c]).toEqual([true, true, true]) - expect(statusCalls).toBe(1) - }) - - it('expires a supported capability verdict so a runtime downgrade is detected', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date(0)) - try { - let statusCalls = 0 - runtimeEnvironmentCall.mockImplementation(() => { - statusCalls += 1 - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: statusCalls === 1 ? ['linear.issue-attribute-filter.v1'] : [] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - }) - - await expect( - runtimeEnvironmentSupportsCapability( - 'env-cap-downgrade', - 'linear.issue-attribute-filter.v1' - ) - ).resolves.toBe(true) - vi.setSystemTime(new Date(59_999)) - await expect( - runtimeEnvironmentSupportsCapability( - 'env-cap-downgrade', - 'linear.issue-attribute-filter.v1' - ) - ).resolves.toBe(true) - vi.setSystemTime(new Date(60_000)) - await expect( - runtimeEnvironmentSupportsCapability( - 'env-cap-downgrade', - 'linear.issue-attribute-filter.v1' - ) - ).resolves.toBe(false) - expect(statusCalls).toBe(2) - } finally { - vi.useRealTimers() - } - }) - - it('invalidates a positive capability verdict when the endpoint runtime changes', async () => { - let statusCalls = 0 - runtimeEnvironmentCall.mockImplementation(() => { - statusCalls += 1 - const runtimeId = statusCalls === 1 ? 'runtime-before-restart' : 'runtime-after-restart' - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId, - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: statusCalls === 1 ? ['agent-session.host-authority.v1'] : [] - }, - _meta: { runtimeId } - }) - }) - - await expect( - runtimeEnvironmentSupportsCapability( - 'env-runtime-replaced', - 'agent-session.host-authority.v1' - ) - ).resolves.toBe(true) - clearRecentRuntimeCompatibilityFailure('env-runtime-replaced', { - runtimeId: 'runtime-after-restart', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } as RuntimeStatus) - await expect( - runtimeEnvironmentSupportsCapability( - 'env-runtime-replaced', - 'agent-session.host-authority.v1' - ) - ).resolves.toBe(false) - expect(statusCalls).toBe(2) - }) - - it('rejects missing advertised runtime capabilities with the caller message', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'status', - ok: true, - result: { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: [] - }, - _meta: { runtimeId: 'remote-runtime' } - }) - - await expect( - assertRuntimeEnvironmentCapability( - 'env-1', - 'project-host-setup.v1', - 'Project setup is unavailable.' - ) - ).rejects.toThrow('Project setup is unavailable.') - }) - it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 9f73ca72dd0d..2b62bac5fd02 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,5 +1,9 @@ +import type { + RuntimeRpcDispatchObservation, + RuntimeRpcResponse +} from '../../../shared/runtime-rpc-envelope' +import { acceptAgentLaunchPaneAttempt } from '@/lib/agent-launch-pane-start' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' @@ -37,9 +41,24 @@ export async function callRuntimeRpc( signal?: AbortSignal expectedEnvironmentPairingRevision?: number expectedEnvironmentRuntimeId?: string - beforeSend?: () => void + beforeSend?: (sentAt?: number) => void + dispatchObservation?: () => RuntimeRpcDispatchObservation } = {} ): Promise { + if ( + (method === 'agent.launch' || method === 'agent.launchReplay') && + params && + typeof params === 'object' && + 'paneKey' in params && + typeof params.paneKey === 'string' + ) { + acceptAgentLaunchPaneAttempt( + params.paneKey, + 'operationId' in params && typeof params.operationId === 'string' + ? params.operationId + : undefined + ) + } if (options.signal?.aborted) { throw createRuntimeRpcAbortError() } @@ -69,15 +88,19 @@ export async function callRuntimeRpc( const nextParams = options.suppressFeatureInteraction ? withBrowserPaneUiRuntimeRpcSource(params) : params - options.beforeSend?.() const response = target.kind === 'local' - ? await window.api.runtime.call({ method, params: nextParams }) + ? await (() => { + options.beforeSend?.() + return window.api.runtime.call({ method, params: nextParams }) + })() : await callRuntimeEnvironmentWithRevision({ environmentId: target.environmentId, method, params: nextParams, timeoutMs: options.timeoutMs, + beforeSend: options.beforeSend, + dispatchObservation: options.dispatchObservation, signal: options.signal, expectedEnvironmentPairingRevision, expectedEnvironmentRuntimeId: options.expectedEnvironmentRuntimeId diff --git a/src/renderer/src/runtime/runtime-rpc-environment-call.ts b/src/renderer/src/runtime/runtime-rpc-environment-call.ts index bde86fe38346..dbbf59389cbf 100644 --- a/src/renderer/src/runtime/runtime-rpc-environment-call.ts +++ b/src/renderer/src/runtime/runtime-rpc-environment-call.ts @@ -1,3 +1,4 @@ +import type { RuntimeRpcDispatchObservation } from '../../../shared/runtime-rpc-envelope' import { callAbortableRuntimeEnvironment } from './abortable-runtime-environment-call' import { observePairingRefusal } from './runtime-environment-pairing-refresh' @@ -12,6 +13,8 @@ async function callRuntimeEnvironmentOnce(args: { method: string params: unknown timeoutMs?: number + beforeSend?: (sentAt?: number) => void + dispatchObservation?: () => RuntimeRpcDispatchObservation signal?: AbortSignal expectedEnvironmentPairingRevision?: number expectedEnvironmentRuntimeId?: string @@ -24,9 +27,12 @@ async function callRuntimeEnvironmentOnce(args: { args.timeoutMs, args.signal, args.expectedEnvironmentPairingRevision, - args.expectedEnvironmentRuntimeId + args.expectedEnvironmentRuntimeId, + args.beforeSend, + args.dispatchObservation?.() ) } + args.beforeSend?.() return window.api.runtimeEnvironments.call({ selector: args.environmentId, method: args.method, diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts index c546a661de5a..180ba6f8c3c4 100644 --- a/src/renderer/src/runtime/server-layout-frames.ts +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -35,7 +35,7 @@ import { type FramesStore = { getState: () => AppState - setState: (patch: Partial) => void + setState: (patch: Partial | ((state: AppState) => Partial)) => void subscribe: (listener: (state: AppState) => void) => () => void } @@ -47,9 +47,13 @@ type ServerFrames = { } /** Workspaces this server's stream draws: ones the window holds that it mirrors from this server. */ -function serverDraws(environmentId: string): (state: AppState) => (key: string) => boolean { - return (state) => (key) => - (key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && +function serverDraws( + environmentId: string +): (state: AppState, openedWorkspaces?: ReadonlySet) => (key: string) => boolean { + return (state, openedWorkspaces) => (key) => + (key in state.groupsByWorktree || + key in state.unifiedTabsByWorktree || + openedWorkspaces?.has(key) === true) && mirroredRuntimeEnvironmentIdForWorkspace(state, key) === environmentId } @@ -153,8 +157,8 @@ function openServerFrames( const sync = openWorkspaceLayoutFrameSync(frameStore, { target: { kind: 'environment', environmentId }, // Only gated workspaces: a handed-back one is the mirror's, whatever the frozen frames still hold. - draws: (state) => { - const ours = draws(state) + draws: (state, openedWorkspaces) => { + const ours = draws(state, openedWorkspaces) return (key) => gated.has(key) && ours(key) }, heldTabs: DESKTOP_CREATED_SERVER_TAB_HOLDS, diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index e3ecb9b31e99..c3a238ecf35d 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -1,3 +1,4 @@ +import type { RuntimeRpcDispatchObservation } from '../../../shared/runtime-rpc-envelope' import type { AgentJournalCursor, AgentJournalRenderItem @@ -8,7 +9,7 @@ import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import type { AgentSessionConversationOutline } from '../../../shared/agent-session/agent-session-conversation-outline' -import { AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS } from '../../../shared/agent-session/agent-session-conversation-command' +import { structuredAgentSessionMethodTimeoutMs } from './structured-agent-session-method-timeouts' import { AGENT_SESSION_ATTENTION_ACK_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, @@ -27,6 +28,11 @@ import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_OPTIONS_MODE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' +import { + inPlaceSwitchCapability, + inPlaceSwitchExecutionCapability, + inPlaceSwitchEnabled +} from '../../../shared/structured-agent-cli-conversation-adoption' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { @@ -92,6 +98,20 @@ export function supportsStructuredAgentSessionCliSource( ) } +/** Whether the destination host switches this agent's conversations in place; unknown is no. */ +export function supportsInPlaceSwitch( + target: RuntimeClientTarget, + agent: string, + execution = false +): Promise { + return inPlaceSwitchEnabled(agent) + ? structuredAgentSessionHostSupports( + target, + execution ? inPlaceSwitchExecutionCapability(agent) : inPlaceSwitchCapability(agent) + ) + : Promise.resolve(false) +} + /** Whether the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. */ export function supportsStructuredAgentSessionQuietRepeatedStop( target: RuntimeClientTarget @@ -150,17 +170,6 @@ export async function readStructuredAgentSessionItem( return result?.item ?? null } -const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = new Map([ - ['agentSession.conversationCommand', AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS], - // The host may start an agent at rest before rewinding it, as it does for a command. - ['agentSession.rewind', 195_000], - // Waiting model catalogs can take Claude’s 60s listing plus 15s account switch and 5s login environment. - ['agentSession.modelCatalog', 90_000], - ['agentSession.sleepWorkspace', 120_000], - // All bounded-concurrency provider starts and dispatch settlements precede the reply. - ['agentSession.restartContinue', 600_000] -]) - export async function callStructuredAgentSession( target: RuntimeClientTarget, method: string, @@ -168,7 +177,9 @@ export async function callStructuredAgentSession( /** For a caller that checked the remote host's compatibility itself, just before. */ options: { skipCompatibilityCheck?: true - beforeSend?: () => void + beforeSend?: (sentAt?: number) => void + dispatchObservation?: () => RuntimeRpcDispatchObservation + signal?: AbortSignal expectedEnvironmentPairingRevision?: number } = {} ): Promise { @@ -182,7 +193,7 @@ export async function callStructuredAgentSession( ) { throw new Error('Rewinding requires a newer Orca server. Update the server and try again.') } - const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method) + const timeoutMs = structuredAgentSessionMethodTimeoutMs(method, params) return timeoutMs === undefined && Object.keys(options).length === 0 ? callRuntimeRpc(target, method, params) : callRuntimeRpc(target, method, params, { diff --git a/src/renderer/src/runtime/structured-agent-session-method-timeouts.ts b/src/renderer/src/runtime/structured-agent-session-method-timeouts.ts new file mode 100644 index 000000000000..c071126911ff --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-method-timeouts.ts @@ -0,0 +1,28 @@ +import { AGENT_SESSION_SWITCH_CLIENT_MS } from '../../../shared/agent-session-switch-execution' +import { AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS } from '../../../shared/agent-session/agent-session-conversation-command' + +export const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = new Map([ + ['agentSession.conversationCommand', AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS], + // The host may start an agent at rest before rewinding it, as it does for a command. + ['agentSession.rewind', 195_000], + // Waiting model catalogs can take Claude’s 60s listing plus 15s account switch and 5s login environment. + ['agentSession.modelCatalog', 90_000], + ['agentSession.sleepWorkspace', 120_000], + // All bounded-concurrency provider starts and dispatch settlements precede the reply. + ['agentSession.restartContinue', 600_000] +]) + +export function structuredAgentSessionMethodTimeoutMs( + method: string, + params: unknown +): number | undefined { + const executionSwitch = + (method === 'agentSession.switchToChat' || method === 'agentSession.switchToTerminal') && + params !== null && + typeof params === 'object' && + 'execution' in params && + Boolean(params.execution) + return executionSwitch + ? AGENT_SESSION_SWITCH_CLIENT_MS + : STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index 778b53c01303..bf18e6b77684 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -80,6 +80,7 @@ export async function syncRuntimeGraph(): Promise { tabs: [], leaves: [], rendererGeneration: mobilePublicationEpoch, + conditionalSwitchProjection: 1, mobileSessionTabs: publication.changed, unchangedMobileSessionWorktrees: publication.unchangedWorktrees, ...(gridSizes ? { terminalGroupGridSizes: gridSizes.groups } : {}), diff --git a/src/renderer/src/runtime/web-runtime-agent-terminal-host-authority.ts b/src/renderer/src/runtime/web-runtime-agent-terminal-host-authority.ts new file mode 100644 index 000000000000..39f060f24e95 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-agent-terminal-host-authority.ts @@ -0,0 +1,91 @@ +import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' +import type { TuiAgent } from '../../../shared/tui-agent' +import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability' +import { agentProviderSessionForHostEnsure } from './agent-session-ensure-provider-session' +import { + createAgentSessionCreateOperation, + withAgentSessionCreateOperationId +} from './agent-session-create-operation' +import { unwrapRuntimeRpcResult } from './runtime-rpc-result' +import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { captureRuntimeEnvironmentCall } from './web-runtime-session-environment' +import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types' +import { readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity' + +export function createWebRuntimeAgentTerminalHostAuthority( + environmentId: string, + args: CreateWebRuntimeSessionTerminalArgs, + agent: TuiAgent, + agentArgsOverride: string | null | undefined, + callEnvironment: ReturnType +) { + // Paired panes retain the default keyboard advertisement, including on Windows clients. + const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard + const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol) + // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. + // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. + // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. + // A switch uses the ordinary prepared command and admission. + const mustUseLegacyAgentCreate = + args.preparedAgentCommand || args.afterTabId || args.resumeOrigin === 'switch' + return mustUseLegacyAgentCreate + ? undefined + : args.agentSessionKind === 'resume' + ? args.providerSession + ? async () => + readCreatedAgentTerminalIdentity( + unwrapRuntimeRpcResult( + await callEnvironment({ + method: 'terminal.ensureAgentSession', + params: { + ...(await keyboardOptions(environmentId)), + kind: 'explicit', + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent, + providerSession: await agentProviderSessionForHostEnsure( + environmentId, + args.providerSession! + ), + ...(args.launchConfig?.ompResumeFilePath + ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } + : {}), + ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), + ...(args.launchPreferences + ? { launchPreferences: args.launchPreferences } + : {}), + presentation: 'background' + }, + timeoutMs: 15_000 + }) + ) + ) + : undefined + : async () => + await createAgentSessionCreateOperation().run(async (clientOperationId) => + readCreatedAgentTerminalIdentity( + unwrapRuntimeRpcResult( + await callEnvironment({ + method: 'terminal.createAgentSession', + params: withAgentSessionCreateOperationId( + { + ...(await keyboardOptions(environmentId)), + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent, + ...(args.prompt ? { prompt: args.prompt } : {}), + ...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}), + ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), + ...(args.launchPreferences + ? { launchPreferences: args.launchPreferences } + : {}), + ...(args.cwd ? { startupCwd: args.cwd } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + presentation: 'background' + }, + clientOperationId + ), + timeoutMs: 15_000 + }) + ) + ) + ) +} diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index e278a0e22b96..7f1e0f7de301 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -149,7 +149,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { snapshot, ENVIRONMENT_ID, undefined, - undefined, + { offerOpenSelection: undefined }, mocks.changedAgentPaneKeys ) expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( @@ -429,7 +429,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { snapshot, ENVIRONMENT_ID, undefined, - undefined, + { offerOpenSelection: undefined }, mocks.changedAgentPaneKeys ) expect(setStateResults.at(-1)).toEqual({ state: 'after' }) diff --git a/src/renderer/src/runtime/web-runtime-session-snapshot.ts b/src/renderer/src/runtime/web-runtime-session-snapshot.ts index cae80cb6df86..c0684f70cc98 100644 --- a/src/renderer/src/runtime/web-runtime-session-snapshot.ts +++ b/src/renderer/src/runtime/web-runtime-session-snapshot.ts @@ -157,7 +157,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( ? decideWebSessionTabsSnapshot(recovered, environmentId) : WEB_SESSION_TABS_FRAME_OUTRANKED const settleMirror = applyWebSessionTabsStorePatch( - (state, changedAgentPaneKeys) => { + (state, changedAgentPaneKeys, offerOpenSelection) => { // Eager refresh can outlive workspace switches; update tabs without taking focus. const patch = decision.apply ? applyWebSessionTabsSnapshot( @@ -165,7 +165,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( recovered, environmentId, undefined, - undefined, + { offerOpenSelection }, changedAgentPaneKeys ) : state diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts index df8551c642b1..b3033b1e56f3 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts @@ -1,3 +1,9 @@ +import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' +import type * as SnapshotApi from './web-session-tabs-sync/snapshot-api' +import type * as StorePatch from './web-session-tabs-sync/store-patch' +import type * as TrackingMappings from './web-session-tabs-sync/tracking-mappings' +import type * as TrackingDecisions from './web-session-tabs-sync/tracking-decisions' +import type * as TrackingLifecycle from './web-session-tabs-sync/tracking-lifecycle' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../../shared/execution-host' import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation' @@ -13,6 +19,7 @@ import { import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' const mocks = vi.hoisted(() => ({ + realStore: false, getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(), @@ -23,7 +30,9 @@ const mocks = vi.hoisted(() => ({ setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), - decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + decideWebSessionTabsSnapshot: vi.fn( + () => ({ apply: true, settlesHostMirror: true }) + ), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -40,24 +49,69 @@ vi.mock('../store', () => ({ } })) -vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ - acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot -})) -vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ - applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot -})) -vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({ - decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot -})) -vi.mock('./web-session-tabs-sync/store-patch', () => ({ - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { - mocks.setState(buildPatch) - return () => {} +vi.mock('./web-session-tabs-sync/tracking-lifecycle', async (original) => { + const actual = await original() + return { + ...actual, + acceptReplayedWebSessionTabsSnapshot: ( + ...args: Parameters + ) => + mocks.realStore + ? actual.acceptReplayedWebSessionTabsSnapshot(...args) + : mocks.acceptReplayedWebSessionTabsSnapshot(...args) } -})) -vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ - resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab -})) +}) +vi.mock('./web-session-tabs-sync/snapshot-api', async (original) => { + const actual = await original() + return { + ...actual, + applyWebSessionTabsSnapshot: ( + ...args: Parameters + ) => + mocks.realStore + ? actual.applyWebSessionTabsSnapshot(...args) + : mocks.applyWebSessionTabsSnapshot(...args) + } +}) +vi.mock('./web-session-tabs-sync/tracking-decisions', async (original) => { + const actual = await original() + return { + ...actual, + decideWebSessionTabsSnapshot: ( + ...args: Parameters + ) => + mocks.realStore + ? actual.decideWebSessionTabsSnapshot(...args) + : mocks.decideWebSessionTabsSnapshot(...args) + } +}) +vi.mock('./web-session-tabs-sync/store-patch', async (original) => { + const actual = await original() + return { + ...actual, + applyWebSessionTabsStorePatch: ( + ...args: Parameters + ) => { + if (mocks.realStore) { + return actual.applyWebSessionTabsStorePatch(...args) + } + mocks.setState(args[0]) + return () => {} + } + } +}) +vi.mock('./web-session-tabs-sync/tracking-mappings', async (original) => { + const actual = await original() + return { + ...actual, + resolveHostSessionTabIdForWebSessionTab: ( + ...args: Parameters + ) => + mocks.realStore + ? actual.resolveHostSessionTabIdForWebSessionTab(...args) + : mocks.resolveHostSessionTabIdForWebSessionTab(...args) + } +}) vi.mock('@/lib/feature-education-telemetry', () => ({ trackTerminalPaneSplit: mocks.trackTerminalPaneSplit @@ -96,6 +150,8 @@ afterEach(() => resetWebSessionCloseIntentForTests()) describe('web-runtime-session terminal workspace routing', () => { beforeEach(() => { + resetWebSessionTabsSnapshotFreshnessForTests() + mocks.realStore = false stubTerminalCreateEnvironment(mocks) }) diff --git a/src/renderer/src/runtime/web-runtime-session-test-harness.ts b/src/renderer/src/runtime/web-runtime-session-test-harness.ts index 9e13fefb0688..ca604ef3c2f4 100644 --- a/src/renderer/src/runtime/web-runtime-session-test-harness.ts +++ b/src/renderer/src/runtime/web-runtime-session-test-harness.ts @@ -191,6 +191,8 @@ export function stubTerminalCreateEnvironment(mocks: WebRuntimeSessionMocks): vo }, activeWorktreeId: WORKTREE_ID, tabsByWorktree: {}, + groupsByWorktree: {}, + unifiedTabsByWorktree: {}, terminalLayoutsByTabId: {}, activeTabIdByWorktree: {}, activeGroupIdByWorktree: {}, diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index aeb015419d91..a09629f34a61 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -39,6 +39,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { agentArgs?: string | null launchPreferences?: AgentLaunchPreferences providerSession?: AgentProviderSessionMetadata + /** A chat's switch: sent only to a host advertising the agent's switch capability. */ + resumeOrigin?: 'switch' viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 086303bf3218..e3a2eeefb504 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -200,7 +200,7 @@ describe('refreshWebRuntimeSessionTabsSnapshot', () => { recovered, ENVIRONMENT_ID, undefined, - undefined, + { offerOpenSelection: undefined }, mocks.changedAgentPaneKeys ) }) @@ -265,7 +265,7 @@ describe('activateWebRuntimeSessionWorktree', () => { snapshot, ENVIRONMENT_ID, undefined, - undefined, + { offerOpenSelection: undefined }, mocks.changedAgentPaneKeys ) expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 01184bb175ad..044cb3494c75 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -1,27 +1,25 @@ -import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' -import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability' +import { presentAgentLaunchTab } from '@/lib/agent-launch-tab-presentation' +import { createWebRuntimeAgentTerminalHostAuthority } from './web-runtime-agent-terminal-host-authority' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import { agentProviderSessionForHostEnsure } from './agent-session-ensure-provider-session' import { translate } from '../i18n/i18n' import { useAppStore } from '../store' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' -import { - createAgentSessionCreateOperation, - withAgentSessionCreateOperationId -} from './agent-session-create-operation' import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { resolveWebRuntimeSessionEnvironmentId } from './web-runtime-session-workspace-routing' -import { recordWebSessionFocusIntent } from './web-session-focus-intent' +import { + recordWebSessionFocusIntent, + resolveWebSessionVisibleTabId +} from './web-session-focus-intent' import { forgetWebSessionTerminalPlacement, recordWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' -import { toHostSessionTabId } from '../../../shared/terminal-surface-id' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { captureRuntimeEnvironmentCall, captureWebSessionIntentOwner, @@ -40,15 +38,17 @@ import { selectWebRuntimeSessionWorktree, type WebRuntimeSessionWorkspaceSelectionRollback } from './web-runtime-session-workspace-selection' +import { createdTerminalLeafId } from './web-runtime-terminal-identity' import { - createdTerminalLeafId, - readCreatedAgentTerminalIdentity -} from './web-runtime-terminal-identity' -import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' + captureWebRuntimeTerminalPlacement, + settleWebRuntimeTerminalPlacement +} from './web-runtime-terminal-placement-settlement' import { buildPlainCreateTerminalParams } from './web-runtime-plain-terminal-create-params' export async function createWebRuntimeSessionTerminalResult( - args: CreateWebRuntimeSessionTerminalArgs + args: CreateWebRuntimeSessionTerminalArgs, + canActivate?: () => boolean, + viewerSelection?: string ): Promise { const environmentId = resolveWebRuntimeSessionEnvironmentId( args.environmentId, @@ -69,7 +69,7 @@ export async function createWebRuntimeSessionTerminalResult( const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) let workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null = null - if (args.selectWorktree !== false) { + if (args.selectWorktree !== false && !canActivate) { const previous = readActiveWorkspaceSelection() selectWebRuntimeSessionWorktree(args.worktreeId, environmentId) workspaceSelectionRollback = { @@ -80,6 +80,7 @@ export async function createWebRuntimeSessionTerminalResult( } } } + const { captured, placementGroupId } = captureWebRuntimeTerminalPlacement(args) let hostCreated = false let createdTabId: string | undefined let createdLeafId: string | undefined @@ -88,78 +89,14 @@ export async function createWebRuntimeSessionTerminalResult( const agentArgsOverride = args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs if (agent) { - // Paired panes retain the default keyboard advertisement, including on Windows clients. - const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard - const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol) let legacyAlreadyPlacedInGroup = false - // Keep legacy placement until structured creation supports afterTabId. - // Focus belongs to this client; headless hosts have no renderer. - // Rebuilding prepared commands under host authority can discard prompts/delivery flags. - const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId - const hostAuthority = mustUseLegacyAgentCreate - ? undefined - : args.agentSessionKind === 'resume' - ? args.providerSession - ? async () => - readCreatedAgentTerminalIdentity( - unwrapRuntimeRpcResult( - await callEnvironment({ - method: 'terminal.ensureAgentSession', - params: { - ...(await keyboardOptions(environmentId)), - kind: 'explicit', - worktree: toRuntimeWorktreeSelector(args.worktreeId), - agent, - providerSession: await agentProviderSessionForHostEnsure( - environmentId, - args.providerSession! - ), - ...(args.launchConfig?.ompResumeFilePath - ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } - : {}), - ...(agentArgsOverride !== undefined - ? { agentArgs: agentArgsOverride } - : {}), - ...(args.launchPreferences - ? { launchPreferences: args.launchPreferences } - : {}), - presentation: 'background' - }, - timeoutMs: 15_000 - }) - ) - ) - : undefined - : async () => - await createAgentSessionCreateOperation().run(async (clientOperationId) => - readCreatedAgentTerminalIdentity( - unwrapRuntimeRpcResult( - await callEnvironment({ - method: 'terminal.createAgentSession', - params: withAgentSessionCreateOperationId( - { - ...(await keyboardOptions(environmentId)), - worktree: toRuntimeWorktreeSelector(args.worktreeId), - agent, - ...(args.prompt ? { prompt: args.prompt } : {}), - ...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}), - ...(agentArgsOverride !== undefined - ? { agentArgs: agentArgsOverride } - : {}), - ...(args.launchPreferences - ? { launchPreferences: args.launchPreferences } - : {}), - ...(args.cwd ? { startupCwd: args.cwd } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}), - presentation: 'background' - }, - clientOperationId - ), - timeoutMs: 15_000 - }) - ) - ) - ) + const hostAuthority = createWebRuntimeAgentTerminalHostAuthority( + environmentId, + args, + agent, + agentArgsOverride, + callEnvironment + ) const resumeHostAuthorityCapability = args.agentSessionKind === 'resume' ? agentResumeHostAuthorityCapability(agent) : undefined const created = await runRemoteAgentSessionLaunch<{ @@ -186,10 +123,13 @@ export async function createWebRuntimeSessionTerminalResult( ...(args.launchToken ? { launchToken: args.launchToken } : {}), ...(args.agent ? { agent: args.agent } : {}), ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.resumeOrigin && args.providerSession + ? { resumeOrigin: args.resumeOrigin, resumeProviderSession: args.providerSession } + : {}), ...(args.viewMode ? { viewMode: args.viewMode } : {}), // Old hosts use activate:false; new hosts use select/navigation for caller-local focus. activate: false, - select: args.activate !== false, + select: args.activate !== false && !canActivate, navigation: 'caller' }, timeoutMs: 15_000 @@ -236,18 +176,33 @@ export async function createWebRuntimeSessionTerminalResult( createdTabId = created.tab.id createdLeafId = created.tab.leafId } - if (args.targetGroupId && createdTabId) { - // Host drops client-minted group IDs; client placement must land the mirrored tab in the requested pane. + if (placementGroupId && createdTabId) { + // Why: the host drops client-minted group ids, so this client's own record is what + // lands the mirrored tab in the requested pane under client-owned placement. recordWebSessionTerminalPlacement({ environmentId, worktreeId: args.worktreeId, hostTabId: webTerminalPlacementParentTabId(createdTabId), - groupId: args.targetGroupId + groupId: placementGroupId }) } - if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { + if ( + viewerSelection === undefined && + args.activate !== false && + canActivate?.() !== false && + createdTabId && + matchesWebSessionIntentOwner(intentOwner) + ) { // Intent lets reconciliation focus this new terminal instead of sticky-keeping the previous tab. - recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId) + recordWebSessionFocusIntent( + intentOwner, + args.worktreeId, + createdTabId, + createdLeafId, + canActivate + ? resolveWebSessionVisibleTabId(useAppStore.getState(), args.worktreeId) + : undefined + ) } const placementTabId = createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined @@ -265,12 +220,28 @@ export async function createWebRuntimeSessionTerminalResult( args.worktreeId, webTerminalPlacementParentTabId(placementTabId), { - groupId: args.targetGroupId, + groupId: placementGroupId, afterTabId: args.afterTabId, - activate: args.activate !== false + captured, + activate: args.activate !== false && viewerSelection === undefined, + canActivate } ) } + if ( + viewerSelection && + createdTabId && + args.activate !== false && + matchesWebSessionIntentOwner(intentOwner) + ) { + presentAgentLaunchTab( + 'focus-window', + args.worktreeId, + toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(createdTabId)), + createdLeafId, + viewerSelection + ) + } return { outcome: { status: 'created' }, ...(createdTabId ? { hostTabId: createdTabId } : {}) diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index 67408edd066a..4c6d8afdd899 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -19,13 +19,22 @@ import type { */ export async function createWebRuntimeSessionTerminal( args: CreateWebRuntimeSessionTerminalArgs, - options: { notifyFailure?: boolean; userAct?: boolean } = {} + options: { + notifyFailure?: boolean + userAct?: boolean + canActivate?: () => boolean + viewerSelection?: string + } = {} ): Promise { if (options.userAct && !(await startStoppedWslHostForUserAct(args.environmentId))) { // The start already told the user why; the host is still stopped. return { status: 'failed', message: stoppedWslHostMessage(args.environmentId) } } - const created = await createWebRuntimeSessionTerminalResult(args) + const created = await createWebRuntimeSessionTerminalResult( + args, + options.canActivate, + options.viewerSelection + ) if ( created.hostFailed && created.outcome.status === 'failed' && diff --git a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts index e737508de280..a39d5dd9eb40 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts @@ -1,3 +1,8 @@ +import { + captureSessionTabPlacement, + placeUnpinnedSessionTabAtCapture, + type CapturedSessionTabPlacement +} from '../../../shared/session-tab-placement' import { insertUnifiedTabAfterAnchor } from '../lib/unified-tab-anchor-insertion' import { useAppStore } from '../store' import { @@ -20,12 +25,38 @@ function anchorUnifiedTabId(worktreeId: string, afterTabId: string): string { : toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(toHostSessionTabId(afterTabId))) } +/** Capture before creation can retire the anchor on the execution host. */ +export function captureWebRuntimeTerminalPlacement(args: { + worktreeId: string + afterTabId?: string + targetGroupId?: string +}) { + const sourceGroup = useAppStore + .getState() + .groupsByWorktree[args.worktreeId]?.find( + (group) => args.afterTabId && group.tabOrder.includes(args.afterTabId) + ) + return { + placementGroupId: args.targetGroupId ?? sourceGroup?.id, + captured: + !args.targetGroupId || args.targetGroupId === sourceGroup?.id + ? captureSessionTabPlacement(sourceGroup?.tabOrder ?? [], args.afterTabId) + : undefined + } +} + /** Settle the placement once the mirrored tab exists (bounded poll), then consume the record. */ export async function settleWebRuntimeTerminalPlacement( environmentId: string, worktreeId: string, hostTabId: string, - placement: { groupId?: string; afterTabId?: string; activate: boolean } + placement: { + groupId?: string + afterTabId?: string + activate: boolean + canActivate?: () => boolean + captured?: CapturedSessionTabPlacement + } ): Promise { const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId) const findTab = () => @@ -58,13 +89,29 @@ export async function settleWebRuntimeTerminalPlacement( if (tab.groupId !== targetGroup.id) { // Snapshot may beat placement recording; repair through the same client-owned move as user drags. state.moveUnifiedTabToGroup(unifiedTabId, targetGroup.id, { - activate: placement.activate, + activate: placement.activate && placement.canActivate?.() !== false, recordInteraction: false }) } - if (anchorId) { - // The create caller owns this insertion; subsequent host snapshots preserve client order. + if (anchorId && targetGroup.tabOrder.includes(anchorId)) { insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, anchorId) + } else if (placement.captured) { + const current = useAppStore.getState() + const group = current.groupsByWorktree[worktreeId]?.find( + (group) => group.id === targetGroup.id + ) + if (group) { + current.reorderUnifiedTabs( + group.id, + placeUnpinnedSessionTabAtCapture( + group.tabOrder, + current.unifiedTabsByWorktree[worktreeId] ?? [], + unifiedTabId, + placement.captured + ), + { recordInteraction: false } + ) + } } } finally { forgetWebSessionTerminalPlacement({ environmentId, worktreeId, hostTabId }) diff --git a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts new file mode 100644 index 000000000000..c1a38417f77e --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts @@ -0,0 +1,531 @@ +// @vitest-environment happy-dom +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { + beginViewerSelectionRequest, + disposeViewerSelection, + viewerSelectionOutcome +} from '@/lib/viewer-selection-request' +import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' +import { registerTerminalUiRoutingIpcBridge } from '@/hooks/ipc-events/terminal-ui-routing-ipc-bridge' +import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' +import { + createWebSessionTabsSnapshotPatch, + applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshotOperations, + applyWebSessionTabsSnapshotOperations +} from './web-session-tabs-sync/snapshot-api' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeOpenFile } from '@/store/slices/store-session-test-harness' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' +import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation' +import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' +import { countOpenPlacementWork } from './open-placement-work-counter.test-fixture' +import { hostSessionTabIdsByLocalTabForWorktree } from './web-session-tabs-sync/tracking-mappings' + +const ENV = 'paired-placement', + WT = 'repo::/repo' +const chat = (id: string) => ({ + type: 'agent-session' as const, + id: `agent-session:${id}`, + sessionId: id, + agent: 'codex' as const, + title: id, + isActive: false +}) +type TerminalFocusCallback = Parameters[0] +function routeTerminalFocus(data: Parameters[0]): void { + let focusTerminal: TerminalFocusCallback = () => { + throw new Error('Terminal focus listener was not registered') + } + const originalApi = window.api + Object.assign(window, { + api: { + ...originalApi, + ui: { + ...originalApi?.ui, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onSetPaneTitle: () => () => {}, + onFocusEditorTab: () => () => {}, + onFocusTerminal: (callback: TerminalFocusCallback) => { + focusTerminal = callback + return () => {} + } + } + } + }) + const unsubs: (() => void)[] = [] + try { + registerTerminalUiRoutingIpcBridge(unsubs) + focusTerminal(data) + } finally { + unsubs.forEach((unsubscribe) => unsubscribe()) + Object.assign(window, { api: originalApi }) + } +} + +let frame: RuntimeMobileSessionTabsResult +function resetFrame() { + resetWebSessionTabsSnapshotFreshnessForTests() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: WT, + activeWorkspaceExecutionHostId: `runtime:${ENV}`, + settings: createGlobalSettingsFixture({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: ENV + }) + }) + const source = ['A', 'C', 'B'] + frame = { + worktree: WT, + publicationEpoch: 'host-placement', + snapshotVersion: 1, + activeGroupId: 'R', + activeTabId: 'agent-session:D', + activeTabType: 'agent-session', + tabs: [...source, 'D'].map(chat), + tabGroups: [ + { + id: 'L', + activeTabId: 'agent-session:C', + tabOrder: source.map((id) => `agent-session:${id}`) + }, + { id: 'R', activeTabId: 'agent-session:D', tabOrder: ['agent-session:D'] } + ] + } + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) +} +beforeEach(() => resetFrame()) +afterEach(() => vi.restoreAllMocks()) +it.each([{ pinned: false }, { pinned: true }])( + 'keeps client neighbors and pin partitions through early retirement and refresh (pinned: $pinned)', + async ({ pinned }) => { + if (pinned) { + frame = { + ...frame, + snapshotVersion: frame.snapshotVersion + 1, + tabs: [ + ...frame.tabs.map((tab) => ({ + ...tab, + isPinned: ['agent-session:A', 'agent-session:C', 'agent-session:B'].includes(tab.id) + })), + chat('U') + ], + tabGroups: frame.tabGroups?.map((group) => + group.id === 'L' ? { ...group, tabOrder: [...group.tabOrder, 'agent-session:U'] } : group + ) + } + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) + } + const before = useAppStore.getState() + const source = before.unifiedTabsByWorktree[WT].find((tab) => tab.entityId === 'C')! + const sourceGroup = source.groupId + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.createTerminal') { + frame = { + ...frame, + snapshotVersion: frame.snapshotVersion + 1, + tabs: [ + ...frame.tabs.filter((tab) => tab.id !== 'agent-session:C'), + { + type: 'terminal', + id: 'T::leaf', + parentTabId: 'T', + leafId: 'leaf', + title: 'Terminal', + terminal: 'term_T', + isActive: false, + status: 'ready' + } + ], + tabGroups: [ + { + id: 'L', + activeTabId: 'T', + tabOrder: [ + 'agent-session:A', + 'agent-session:B', + 'T', + ...(pinned ? ['agent-session:U'] : []) + ] + }, + frame.tabGroups![1] + ] + } + // Publication beats the create response, with C already gone and T at the host's old fallback. + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) + expect( + useAppStore.getState().unifiedTabsByWorktree[WT].some((tab) => tab.entityId === 'C') + ).toBe(false) + useAppStore.getState().createUnifiedTab(WT, 'editor', { + id: 'concurrent', + entityId: '/repo/concurrent', + targetGroupId: sourceGroup + }) + return { id: 'create', ok: true as const, result: { tab: frame.tabs.at(-1) } } + } + if (method === 'session.tabs.list') { + return { id: 'list', ok: true as const, result: frame } + } + throw new Error(`Unexpected ${method}`) + }) + Object.assign(window, { + api: { + ...window.api, + runtimeEnvironments: { ...window.api?.runtimeEnvironments, call: runtimeCall } + } + }) + const result = await createWebRuntimeSessionTerminalResult({ + environmentId: ENV, + worktreeId: WT, + afterTabId: source.id, + targetGroupId: sourceGroup, + launchAgent: 'codex', + command: 'codex resume conversation', + resumeOrigin: 'switch', + providerSession: { key: 'session_id', id: 'conversation' }, + selectWorktree: false, + activate: true + }) + expect(result.outcome.status).toBe('created') + const order = () => + useAppStore.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroup)! + .tabOrder + const expected = [ + 'structured-agent-session-A', + ...(pinned ? ['structured-agent-session-B'] : []), + toWebTerminalSurfaceTabId('T'), + ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), + 'concurrent' + ] + expect(order()).toEqual(expected) + expect(useAppStore.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroup) + await refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { acceptCurrentSnapshot: true }) + expect(order()).toEqual(expected) + } +) + +it.each([10, 100])( + 'indexes retained metadata once on an admitted unchanged snapshot replay (%i destinations)', + (count) => { + disposeViewerSelection() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: WT, + activeView: 'terminal', + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: ENV }) + }) + const leafId = '10000000-0000-4000-8000-000000000001' + const terminal = (id: string) => ({ + type: 'terminal' as const, + id: `${id}::${leafId}`, + parentTabId: id, + leafId, + title: id, + terminal: `term_${id}`, + isActive: false, + status: 'ready' as const + }) + let snapshot: RuntimeMobileSessionTabsResult = { + worktree: WT, + publicationEpoch: 'metadata-replay', + snapshotVersion: 1, + activeGroupId: 'G', + activeTabId: 'C', + activeTabType: 'terminal', + tabs: [terminal('C')], + tabGroups: [{ id: 'G', activeTabId: 'C', tabOrder: ['C'] }] + } + const applyAdmitted = () => { + const operations = decideWebSessionTabsSnapshotOperations([{ environmentId: ENV, snapshot }]) + expect(operations[0].decision.apply).toBe(true) + applyWebSessionTabsStorePatch( + (state, keys, offerOpenSelection) => + applyWebSessionTabsSnapshotOperations(state, operations, keys, { offerOpenSelection }), + { frames: [] } + )() + } + applyAdmitted() + const groupId = useAppStore.getState().groupsByWorktree[WT][0].id + const deadline = agentSessionSwitchDeadline(Date.now) + let counter: ReturnType | undefined + try { + const requests = Array.from({ length: count }, (_, i) => { + const request = beginViewerSelectionRequest(`retained-${i}`, deadline) + request.accept() + return request + }) + snapshot = { + ...snapshot, + snapshotVersion: 2, + tabs: [ + terminal('C'), + ...requests.map((request, i) => ({ + ...terminal(`D-${i}`), + openPlacement: { + invocationId: `retained-${i}`, + generation: `generation-${i}`, + placement: { groupId, index: i + 1 }, + activate: true, + viewerSelection: request.token, + source: { + worktreeId: WT, + tabId: `absent-source-${i}`, + leafId, + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: `source-pty-${i}`, + incarnationId: null + } + } + })) + ], + tabGroups: [ + { id: 'G', activeTabId: 'C', tabOrder: ['C', ...requests.map((_, i) => `D-${i}`)] } + ] + } + applyAdmitted() + expect(requests.map((request) => viewerSelectionOutcome(request.token))).toEqual([ + ...Array.from({ length: count - 1 }, () => 'superseded'), + 'fulfilled' + ]) + const before = useAppStore.getState() + const order = before.groupsByWorktree[WT][0].tabOrder + const selected = before.activeTabIdByWorktree[WT] + const mappingCount = hostSessionTabIdsByLocalTabForWorktree(ENV, WT).size + expect(before.unifiedTabsByWorktree[WT]).toHaveLength(count + 1) + expect(before.tabsByWorktree[WT]).toHaveLength(count + 1) + expect(mappingCount).toBe(count + 1) + counter = countOpenPlacementWork() + // The host republishes unchanged inventory at a newer admitted version, after selection settled. + snapshot = { ...snapshot, snapshotVersion: 3 } + applyAdmitted() + expect(counter.visits).toEqual([{ pass: 'snapshot', worktreeId: WT, rows: count + 1 }]) + expect(counter.reads).toEqual([ + { + pass: 'snapshot', + worktreeId: WT, + tabRows: 2 * (count + 1), + terminalRows: count + 1, + snapshotRows: count + 1, + mappingCopies: mappingCount, + mappingRows: mappingCount, + sourceChecks: count, + viewerChecks: count, + destinationLookups: count + } + ]) + expect(counter.fingerprints()).toBe(0) + const after = useAppStore.getState() + expect(after.groupsByWorktree[WT][0].tabOrder).toEqual(order) + expect(after.activeTabIdByWorktree[WT]).toBe(selected) + expect(after.tabsByWorktree[WT]).toHaveLength(count + 1) + expect(after.tabsByWorktree[WT].some((tab) => tab.id.startsWith('absent-source-'))).toBe( + false + ) + expect(viewerSelectionOutcome(requests.at(-1)!.token)).toBe('fulfilled') + } finally { + counter?.stop() + deadline.dispose() + disposeViewerSelection() + } + } +) + +it.each([ + { + owner: 'local-structured-session', + direction: 'chat', + inventoryFirst: false, + navigation: 'notification' + }, + { + owner: 'local-structured-session', + direction: 'terminal', + inventoryFirst: true, + navigation: 'shortcut' + }, + { owner: ENV, direction: 'chat', inventoryFirst: true, navigation: 'shortcut' }, + { owner: ENV, direction: 'terminal', inventoryFirst: false, navigation: 'shortcut' } +] as const)( + 'consumes the original viewer choice once at the real $owner $direction snapshot writer ($navigation)', + async ({ owner, direction, inventoryFirst, navigation }) => { + const worktreeId = navigation === 'notification' ? 'folder:folder-1' : WT + for (const navigate of [false, true]) { + disposeViewerSelection() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ activeWorktreeId: worktreeId, activeView: 'terminal' }) + const leafId = '10000000-0000-4000-8000-000000000001' + const terminal = (id: string) => ({ + type: 'terminal' as const, + id: `${id}::${leafId}`, + parentTabId: id, + leafId, + title: id, + terminal: `term_${id}`, + isActive: false, + status: 'ready' as const + }) + const source = direction === 'chat' ? terminal('A') : chat('A') + let snapshot: RuntimeMobileSessionTabsResult = { + worktree: worktreeId, + publicationEpoch: 'selection', + snapshotVersion: 1, + activeGroupId: 'G', + activeTabId: source.id, + activeTabType: source.type, + tabs: [source, terminal('B'), terminal('C')], + tabGroups: [ + { + id: 'G', + activeTabId: source.id, + tabOrder: [source.type === 'terminal' ? 'A' : source.id, 'B', 'C'] + } + ] + } + const apply = () => + applyWebSessionTabsStorePatch(createWebSessionTabsSnapshotPatch(snapshot, owner), { + frames: [] + })() + apply() + const initial = useAppStore.getState() + const sourceTab = initial.unifiedTabsByWorktree[worktreeId].find( + (tab) => tab.entityId === (direction === 'chat' ? toWebTerminalSurfaceTabId('A') : 'A') + )! + const siblings = ['B', 'C'].map((id) => + initial.unifiedTabsByWorktree[worktreeId].find( + (tab) => tab.entityId === toWebTerminalSurfaceTabId(id) + )! + ) + const groupId = sourceTab.groupId + useAppStore.setState({ + openFiles: [makeOpenFile({ id: '/repo/client-only', worktreeId })] + }) + initial.createUnifiedTab(worktreeId, 'editor', { + id: 'X', + entityId: '/repo/client-only', + targetGroupId: groupId, + afterTabId: sourceTab.id, + activate: false + }) + const deadline = agentSessionSwitchDeadline(Date.now) + try { + const selection = beginViewerSelectionRequest('snapshot-choice', deadline) + selection.accept() + const openPlacement = { + invocationId: 'snapshot-choice', + generation: 'snapshot-generation', + placement: { groupId, afterTabId: sourceTab.id, index: 0 }, + activate: true, + viewerSelection: selection.token + } + // Natural disappearance and ordinary fallback are not navigation. + snapshot = { + ...snapshot, + snapshotVersion: 2, + tabs: snapshot.tabs.slice(1), + tabGroups: [{ id: 'G', activeTabId: 'C', tabOrder: ['B', 'C'] }] + } + apply() + if (navigation === 'notification') { + routeTerminalFocus({ + tabId: toWebTerminalSurfaceTabId('C'), + worktreeId, + leafId, + ...(navigate + ? { + ackPaneKeyOnSuccess: makePaneKey(toWebTerminalSurfaceTabId('C'), leafId), + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + } + : {}) + }) + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + expect(useAppStore.getState().activeWorktreeId).toBe(worktreeId) + expect(useAppStore.getState().activeTabIdByWorktree[worktreeId]).toBe( + toWebTerminalSurfaceTabId('C') + ) + } else if (navigate) { + // A real shortcut/re-click, including ABA, must supersede the captured gesture. + expect(activateTabNumberShortcut(2)).toBe(true) + expect(activateTabNumberShortcut(1)).toBe(true) + expect(activateTabNumberShortcut(2)).toBe(true) + } + const userChoice = useAppStore.getState().activeTabIdByWorktree[worktreeId] + const destination = direction === 'chat' ? chat('canonical-D') : terminal('D') + snapshot = { + ...snapshot, + snapshotVersion: 3, + tabs: [destination, ...snapshot.tabs], + tabGroups: [ + { + id: 'G', + activeTabId: 'C', + tabOrder: [destination.type === 'terminal' ? 'D' : destination.id, 'B', 'C'] + } + ] + } + if (inventoryFirst) { + snapshot = { + ...snapshot, + tabs: snapshot.tabs.map((row) => + row.id === destination.id + ? { ...row, openPlacement: { ...openPlacement, activate: false } } + : row + ) + } + apply() + } + if (navigation !== 'notification' || !navigate) { + expect(viewerSelectionOutcome(selection.token)).toBe(navigate ? 'superseded' : 'pending') + } + snapshot = { + ...snapshot, + snapshotVersion: 4, + tabs: snapshot.tabs.map((row) => + row.id === destination.id ? { ...row, openPlacement } : row + ) + } + apply() + const after = useAppStore.getState() + const landed = after.unifiedTabsByWorktree[worktreeId].find( + (row) => + row.entityId === (direction === 'chat' ? 'canonical-D' : toWebTerminalSurfaceTabId('D')) + )! + expect(landed).toBeDefined() + const group = after.groupsByWorktree[worktreeId].find((row) => row.id === groupId)! + expect(group.tabOrder).toEqual([landed.id, 'X', ...siblings.map((row) => row.id)]) + expect(group.activeTabId).toBe(navigate ? userChoice : landed.id) + expect(viewerSelectionOutcome(selection.token)).toBe(navigate ? 'superseded' : 'fulfilled') + expect( + after.unifiedTabsByWorktree[worktreeId].filter((row) => row.id === landed.id) + ).toHaveLength(1) + apply() + expect( + useAppStore.getState().groupsByWorktree[worktreeId].find((row) => row.id === groupId)! + .activeTabId + ).toBe(group.activeTabId) + // The same retained metadata is inventory after fulfilment, even after an ordinary repair. + expect(activateTabNumberShortcut(3)).toBe(true) + const later = useAppStore.getState().activeTabIdByWorktree[worktreeId] + apply() + expect(useAppStore.getState().activeTabIdByWorktree[worktreeId]).toBe(later) + expect( + useAppStore.getState().groupsByWorktree[worktreeId].find((row) => row.id === groupId)! + .tabOrder + ).toEqual(group.tabOrder) + } finally { + deadline.dispose() + } + } + } +) diff --git a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts index 9ac23eefd73f..bfa23453c901 100644 --- a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts +++ b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts @@ -1,5 +1,7 @@ import { structuredLaunchSlotIds } from '@/lib/agent-session/launch/structured-agent-session-launch-persistence' -import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' +import type { SessionTabOpenPlacement } from '../../../shared/session-tab-placement' +import { insertOpenPlacementTab } from '../lib/session-tab-open-placement-order' import { pickNextActiveTab, pushRecentTabId, @@ -10,6 +12,7 @@ import { export type ClientOwnedAdoptedTab = { tabId: string groupId: string + openPlacement?: SessionTabOpenPlacement['placement'] } export type ClientOwnedPlacementInput = { @@ -19,6 +22,7 @@ export type ClientOwnedPlacementInput = { validUnifiedTabIds: ReadonlySet /** Never-seen tabs in snapshot order; groupId is a hint (host group or fallback). */ adoptedTabs: readonly ClientOwnedAdoptedTab[] + tabs?: readonly Tab[] /** Client-recorded placements; these are authoritative and may move an already-placed tab. */ placementMoves: readonly ClientOwnedAdoptedTab[] /** Identity rekeys (provisional/local unified id → mirrored id); placement carries over. */ @@ -175,7 +179,14 @@ export function reconcileClientOwnedTabPlacement( } const target = ensureGroup(targetGroupId) if (!target.tabOrder.includes(adopted.tabId)) { - target.tabOrder.push(adopted.tabId) + target.tabOrder = adopted.openPlacement + ? insertOpenPlacementTab( + target.tabOrder, + input.tabs ?? [], + adopted.tabId, + adopted.openPlacement + ) + : [...target.tabOrder, adopted.tabId] } } diff --git a/src/renderer/src/runtime/web-session-close-intent.ts b/src/renderer/src/runtime/web-session-close-intent.ts index 70bedc512859..637941866a3a 100644 --- a/src/renderer/src/runtime/web-session-close-intent.ts +++ b/src/renderer/src/runtime/web-session-close-intent.ts @@ -9,7 +9,7 @@ export const WEB_SESSION_CLOSE_INTENT_TTL_MS = WEB_SESSION_TAB_RPC_TIMEOUT_MS + CLOSE_INTENT_ANSWER_GRACE_MS export const MAX_WEB_SESSION_CLOSE_INTENT_PARTITIONS = 512 -type CloseIntent = { recordedAt: number; durable: boolean } +type CloseIntent = { expiresAt: number; durable: boolean } const pendingCloseByOwnerAndWorktree = new Map>() @@ -21,8 +21,9 @@ export function recordWebSessionCloseIntent( owner: WebSessionIntentOwner, worktreeId: string, hostTabId: string, - now: number -): void { + now: number, + ttlMs = WEB_SESSION_CLOSE_INTENT_TTL_MS +): (() => void) | undefined { const trimmed = hostTabId.trim() if (!worktreeId || !trimmed) { return @@ -40,7 +41,20 @@ export function recordWebSessionCloseIntent( } pendingCloseByOwnerAndWorktree.set(partitionKey, byTab) } - byTab.set(trimmed, { recordedAt: now, durable: byTab.get(trimmed)?.durable === true }) + const durable = ttlMs === WEB_SESSION_CLOSE_INTENT_TTL_MS && byTab.get(trimmed)?.durable === true + const intent = { expiresAt: now + ttlMs, durable } + byTab.set(trimmed, intent) + return () => { + if ( + byTab?.get(trimmed) === intent && + pendingCloseByOwnerAndWorktree.get(partitionKey) === byTab + ) { + byTab.delete(trimmed) + if (byTab.size === 0) { + pendingCloseByOwnerAndWorktree.delete(partitionKey) + } + } + } } // Definitive tab_not_found can coexist with stale republished surfaces (#9194); no TTL or unclosable panes return. Clear when a snapshot omits the surface so recovered tabs are not suppressed forever. @@ -69,7 +83,7 @@ export function isWebSessionCloseIntentPending( if (!intent) { return false } - if (!intent.durable && now - intent.recordedAt > WEB_SESSION_CLOSE_INTENT_TTL_MS) { + if (!intent.durable && now > intent.expiresAt) { byTab!.delete(hostTabId) if (byTab!.size === 0) { pendingCloseByOwnerAndWorktree.delete(partitionKey) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index 261c036aec83..324f7a0bebe8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -188,8 +188,8 @@ export function buildRetractedMirroredTabSweepPatch( let sweepState: RetiredTerminalTabSweepState = { acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, activityClearedAtByPaneKey: state.activityClearedAtByPaneKey ?? {}, - agentConversationsByPaneKey: state.agentConversationsByPaneKey ?? {}, agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, + agentConversationsByPaneKey: state.agentConversationsByPaneKey ?? {}, agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts index 02f59921677b..bcc7b4ee70b3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -34,6 +34,7 @@ import { isTerminalRowRetiredByHost } from './host-retired-terminal-rows' import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' +import { prepareSnapshotOpenPlacements } from './open-placement' export function prepareWebSessionTabsSnapshotBase( state: WebSessionTabsSyncState, @@ -44,6 +45,7 @@ export function prepareWebSessionTabsSnapshotBase( batchContext: WebSessionTabsBatchContext | undefined, options: WebSessionTabsSnapshotApplyOptions | undefined ) { + const openPlacements = prepareSnapshotOpenPlacements(state, rawSnapshot, environmentId, now) const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => tab.type === 'terminal' ? tab.parentTabId : tab.id reconcileWebSessionCloseIntents( @@ -219,6 +221,7 @@ export function prepareWebSessionTabsSnapshotBase( now, batchContext, options, + openPlacements, focusIntent, matchingFocusIntentTab, callerFocusIntentTab, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts index d73a113b6fe7..8cdeab57ce66 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts @@ -9,6 +9,7 @@ import { peekWebSessionTerminalPlacementGroup } from '../web-session-terminal-pl import { reconcileClientOwnedTabPlacement } from '../web-session-client-owned-tab-placement' import { buildMirroredHostGroups, retainClientPlacedMirroredTabs } from './layout-groups' import { sanitizeRecentTabIds, pushRecentTabId } from './state-equality-core' +import { insertOpenPlacementTab } from '../../lib/session-tab-open-placement-order' export function prepareWebSessionTabsSnapshotGroups( base: ReturnType @@ -38,6 +39,36 @@ export function prepareWebSessionTabsSnapshotGroups( reservedEmptyPreviewFallbackTabId } = base const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const currentGroupIds = new Set(currentGroups.map((group) => group.id)) + const currentTabsById = new Map< + string | undefined, + (typeof state.unifiedTabsByWorktree)[string][number] + >() + for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { + if (!currentTabsById.has(tab.id)) { + currentTabsById.set(tab.id, tab) + } + } + const openPlacementByTabId = new Map( + base.openPlacements.flatMap((entry) => { + const tabId = hostToLocalTabId.get(entry.hostTabId) + if (!tabId) { + return [] + } + const anchor = currentTabsById.get(entry.placement.afterTabId) + return [ + [ + tabId, + { + ...entry.placement, + groupId: currentGroupIds.has(entry.placement.groupId) + ? entry.placement.groupId + : (anchor?.groupId ?? entry.placement.groupId) + } + ] as const + ] + }) + ) const clientGroupIdByLocalTabId = new Map( mirroredBrowserTabs.flatMap((entry) => entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] @@ -97,13 +128,18 @@ export function prepareWebSessionTabsSnapshotGroups( .filter((tab) => !knownGroupTabIds.has(tab.id)) .map((tab) => ({ tabId: tab.id, - groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId + groupId: + openPlacementByTabId.get(tab.id)?.groupId ?? + clientGroupIdByLocalTabId.get(tab.id) ?? + tab.groupId, + openPlacement: openPlacementByTabId.get(tab.id) })) return reconcileClientOwnedTabPlacement({ currentGroups, worktreeId, validUnifiedTabIds, adoptedTabs, + tabs: nextUnifiedTabs, placementMoves, rekeyedTabIds, intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null, @@ -213,7 +249,15 @@ export function prepareWebSessionTabsSnapshotGroups( for (const tabId of hostOrMirroredOrder) { push(tabId) } - return next + const currentIds = new Set(current) + let placed = next + for (const [tabId, placement] of openPlacementByTabId) { + if (currentIds.has(tabId) || !validTabBarIds.has(tabId)) { + continue + } + placed = insertOpenPlacementTab(placed, nextUnifiedTabs ?? [], tabId, placement) + } + return placed })() return { ...base, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts index 87333d16dfcf..f3acaabf4f33 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts @@ -20,6 +20,7 @@ import { buildWebSessionTabsFinalPatch } from './apply-final-patch' import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces' import { buildFrameDrawnWorkspaceStatusPatch } from './frame-drawn-workspace-status-patch' import { indexMirroredTerminalSurfaces } from './mirrored-terminal-handle-index' +import { applySnapshotOpenSelection } from './open-placement-selection' export function applyWebSessionTabsSnapshotWithContext( state: WebSessionTabsSyncState, @@ -78,5 +79,24 @@ export function applyWebSessionTabsSnapshotWithContext( const browserRecords = applyBrowserRecordUpdates(terminalRecords) const worktreeRecords = applyWorktreeRecordUpdates(browserRecords) const activeState = applyActiveStateUpdates(worktreeRecords) - return buildWebSessionTabsFinalPatch(activeState) + const patch = buildWebSessionTabsFinalPatch(activeState) + options?.offerOpenSelection?.((current, membership) => + applySnapshotOpenSelection( + current, + membership, + worktreeId, + environmentId, + base.openPlacements, + unified.hostToLocalTabId, + true + ) + ) + return applySnapshotOpenSelection( + state, + patch, + worktreeId, + environmentId, + base.openPlacements, + unified.hostToLocalTabId + ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index ad7eca835864..3ccc2cd55f1b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -131,13 +131,14 @@ export function handleGlobalSessionInventoryEvent({ decisions[index]!.apply ? [snapshot] : [] ) settleHydration = applyWebSessionTabsStorePatch( - (state, changedAgentPaneKeys) => + (state, changedAgentPaneKeys, offerOpenSelection) => applyWebSessionTabsSnapshots( state, freshSnapshots, environmentId, undefined, - changedAgentPaneKeys + changedAgentPaneKeys, + { offerOpenSelection } ), { frames: applicable.map(({ snapshot }, index) => ({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index 1bbcd81f03bc..8c5cec1ccf44 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -128,13 +128,14 @@ export function loadInitialWebSessionTabs({ (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= requestReceivedFrame settleHydration = applyWebSessionTabsStorePatch( - (state, changedAgentPaneKeys) => + (state, changedAgentPaneKeys, offerOpenSelection) => applyWebSessionTabsSnapshots( state, freshSnapshots, environmentId, undefined, - changedAgentPaneKeys + changedAgentPaneKeys, + { offerOpenSelection } ), { frames: applicable.map((snapshot, index) => ({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/open-placement-read.ts b/src/renderer/src/runtime/web-session-tabs-sync/open-placement-read.ts new file mode 100644 index 000000000000..126ec13de170 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/open-placement-read.ts @@ -0,0 +1,126 @@ +import type { SessionTabOpenPlacement } from '../../../../shared/session-tab-placement' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + captureTerminalSurfaceIdentityFromRow, + isRetainedTerminalSurfaceIdentity +} from '../../../../shared/terminal-tab-close' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import { conversationSwitchViewerSelectionCurrent } from '../../lib/conversation-switch-selection' +import { isWebSessionCloseIntentPending } from '../web-session-close-intent' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' +import { getRemoteRuntimePtyOwner } from '../runtime-terminal-stream' +import { hostSessionTabIdsByLocalTabForWorktree } from './tracking-mappings' +import type { WebSessionTabsSyncState } from './state' + +/** Call-local indexes of the same ordinary transaction input, never retained across applies. */ +export function createOpenPlacementRead( + state: WebSessionTabsSyncState, + worktreeId: string, + environmentId: string, + snapshot?: RuntimeMobileSessionTabsResult, + now = Date.now() +) { + const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const entityIds = new Map>() + for (const tab of tabs) { + const ids = entityIds.get(tab.contentType) ?? new Set() + ids.add(tab.entityId) + entityIds.set(tab.contentType, ids) + } + const localByHost = new Map() + for (const [localId, hostId] of hostSessionTabIdsByLocalTabForWorktree( + environmentId, + worktreeId + )) { + if (!localByHost.has(hostId)) { + localByHost.set(hostId, localId) + } + } + const terminalRows = new Map() + for (const row of state.tabsByWorktree[worktreeId] ?? []) { + if (!terminalRows.has(row.id)) { + terminalRows.set(row.id, row) + } + } + type Surface = Extract['tabs'][number], { type: 'terminal' }> + const surfaces = new Map>() + for (const tab of snapshot?.tabs ?? []) { + if (tab.type === 'terminal') { + const leaves = surfaces.get(tab.parentTabId) ?? new Map() + if (!leaves.has(tab.leafId)) { + leaves.set(tab.leafId, tab) + } + surfaces.set(tab.parentTabId, leaves) + } + } + const closingHostIds = new Set() + for (const close of state.pairedTerminalTabCloseIntentsByEnvironment?.[environmentId] ?? []) { + if (close.worktreeId === worktreeId) { + for (const hostId of close.hostTabIds) { + closingHostIds.add(hostId) + } + } + } + const localTabId = (hostId: string): string => { + const mirroredId = toWebTerminalSurfaceTabId(hostId) + return ( + localByHost.get(hostId) ?? + (tabIds.has(hostId) ? hostId : tabIds.has(mirroredId) ? mirroredId : hostId) + ) + } + return { + localTabId, + hasTab: (id: string) => tabIds.has(id), + hasEntity: (kind: string, id: string) => entityIds.get(kind)?.has(id) === true, + viewerSelectionCurrent: (original?: string): boolean => + conversationSwitchViewerSelectionCurrent(original, state), + sourceCurrent: (intent: SessionTabOpenPlacement): boolean => { + const source = intent.source + if (!source) { + return true + } + const tabId = localTabId(source.tabId) + if ( + source.worktreeId !== worktreeId || + state.closedTerminalTabTombstonesByTabId?.[tabId] || + closingHostIds.has(source.tabId) || + isWebSessionCloseIntentPending({ environmentId }, worktreeId, source.tabId, now) + ) { + return false + } + const row = terminalRows.get(tabId) + const current = captureTerminalSurfaceIdentityFromRow( + state, + row, + worktreeId, + tabId, + source.leafId + ) + if (environmentId === LOCAL_STRUCTURED_SESSION_OWNER || tabId === source.tabId) { + return isRetainedTerminalSurfaceIdentity(source, current) + } + const surface = surfaces.get(source.tabId)?.get(source.leafId) + if (surface) { + if ( + (surface.ptyId != null && surface.ptyId !== source.ptyId) || + (surface.incarnationId != null && surface.incarnationId !== source.incarnationId) + ) { + return false + } + if ( + current?.ptyId && + typeof surface.terminal === 'string' && + getRemoteRuntimePtyOwner(current.ptyId)?.terminal !== surface.terminal + ) { + return false + } + } + // Mirror creation times belong to this viewer; host PTY/incarnation facts belong to the source. + return ( + current === null || + (current.generation === source.generation && (!current.hasLeaf || source.hasLeaf)) + ) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/open-placement-selection.ts b/src/renderer/src/runtime/web-session-tabs-sync/open-placement-selection.ts new file mode 100644 index 000000000000..6abc6e1ee6eb --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/open-placement-selection.ts @@ -0,0 +1,108 @@ +import { isSessionTabSelectionRequest } from '../../../../shared/session-tab-placement' +import { commitViewerSelection } from '@/lib/viewer-selection-request' +import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' +import { isWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { appendHistoryEntry } from '../../store/slices/worktree-nav-history' +import { tabActivationPatch } from '../../store/slices/tabs/tabs-focus-actions' +import { deriveActiveSurfaceForWorktree } from '../../store/slices/tabs/tabs-surface' +import { executionHostIdForSessionTabsOwner } from '../local-structured-session-owner' +import type { SnapshotOpenPlacement } from './open-placement' +import type { WebSessionTabsSyncState } from './state' +import { snapshotOpenSelectionRecords } from './store-patch' + +/** Membership and original-view admission share one synchronous ordinary snapshot transaction. */ +export function applySnapshotOpenSelection( + state: WebSessionTabsSyncState, + patch: WebSessionTabsSyncState | Partial, + worktreeId: string, + environmentId: string, + placements: readonly SnapshotOpenPlacement[], + hostToLocalTabId: ReadonlyMap, + consume = false, + onSelection?: (patch: Partial) => void +): WebSessionTabsSyncState | Partial { + const requested = + placements.find( + (entry) => entry.activate && isSessionTabSelectionRequest(entry.intent.viewerSelection) + ) ?? placements.find((entry) => entry.activate) + if (!requested) { + return patch + } + const localId = hostToLocalTabId.get(requested.hostTabId) + return localId + ? applyOpenDestinationSelection( + state, + patch, + worktreeId, + environmentId, + localId, + requested.intent.viewerSelection, + consume, + onSelection + ) + : patch +} + +export function applyOpenDestinationSelection( + state: WebSessionTabsSyncState, + patch: Partial, + worktreeId: string, + environmentId: string, + tabId: string, + token: string | undefined, + consume: boolean, + onSelection?: (patch: Partial) => void +): Partial { + if (token && isSessionTabSelectionRequest(token) && !consume) { + return patch + } + const next = { ...state, ...patch } + const tab = next.unifiedTabsByWorktree[worktreeId]?.find( + (row) => row.id === tabId || row.entityId === tabId + ) + if ( + !tab || + !next.groupsByWorktree[worktreeId]?.some((group) => group.tabOrder.includes(tab.id)) + ) { + return patch + } + const activation = tabActivationPatch(next, tab, worktreeId, { recordFocus: false }, true) + const activated = { ...next, ...activation } + const surface = deriveActiveSurfaceForWorktree(activated, worktreeId, tab.groupId, { + preferredTabId: tab.id + }) + const selectionPatch = { + ...activation, + ...snapshotOpenSelectionRecords(next, worktreeId, surface), + ...(isFloatingWorkspaceId(worktreeId) + ? {} + : { + ...surface, + ...(state.worktreeNavHistory && + state.worktreeNavHistoryIndex !== undefined && + !state.isNavigatingHistory + ? appendHistoryEntry( + { + worktreeNavHistory: state.worktreeNavHistory, + worktreeNavHistoryIndex: state.worktreeNavHistoryIndex + }, + worktreeId + ) + : {}), + activeWorktreeId: worktreeId, + activeWorkspaceKey: isWorkspaceKey(worktreeId) + ? worktreeId + : worktreeWorkspaceKey(worktreeId), + activeWorkspaceExecutionHostId: executionHostIdForSessionTabsOwner(environmentId), + activePendingCreationId: null, + activeView: 'terminal' as const + }) + } + if (token && isSessionTabSelectionRequest(token)) { + return consume && commitViewerSelection(token, { worktreeId, tabId: tab.id }, () => true) + ? (onSelection?.(selectionPatch), { ...patch, ...selectionPatch }) + : patch + } + onSelection?.(selectionPatch) + return { ...patch, ...selectionPatch } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/open-placement.ts b/src/renderer/src/runtime/web-session-tabs-sync/open-placement.ts new file mode 100644 index 000000000000..050720394a20 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/open-placement.ts @@ -0,0 +1,82 @@ +import type { SessionTabOpenPlacement } from '../../../../shared/session-tab-placement' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { createOpenPlacementRead } from './open-placement-read' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' +import type { WebSessionTabsSyncState } from './state' + +export type SnapshotOpenPlacement = { + hostTabId: string + intent: SessionTabOpenPlacement + placement: SessionTabOpenPlacement['placement'] + activate: boolean +} + +export function localOpenPlacementTabId( + state: WebSessionTabsSyncState, + worktreeId: string, + hostTabId: string, + environmentId: string +): string { + return createOpenPlacementRead(state, worktreeId, environmentId).localTabId(hostTabId) +} + +/** Placement never grants authority over a source the user closed or replaced. */ +export function openPlacementSourceCurrent( + state: WebSessionTabsSyncState, + worktreeId: string, + intent: SessionTabOpenPlacement, + environmentId = LOCAL_STRUCTURED_SESSION_OWNER, + snapshot?: RuntimeMobileSessionTabsResult, + now = Date.now() +): boolean { + return ( + !intent.source || + createOpenPlacementRead(state, worktreeId, environmentId, snapshot, now).sourceCurrent(intent) + ) +} + +/** Read before close-ledger reconciliation and identity-map replacement in the same apply. */ +export function prepareSnapshotOpenPlacements( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now: number +): SnapshotOpenPlacement[] { + let read: ReturnType | undefined + return snapshot.tabs.flatMap((tab) => { + if (tab.type !== 'terminal' && tab.type !== 'agent-session') { + return [] + } + const intent = tab.openPlacement + if (!intent) { + return [] + } + read ??= createOpenPlacementRead(state, snapshot.worktree, environmentId, snapshot, now) + if (!read.sourceCurrent(intent)) { + return [] + } + const hostTabId = tab.type === 'terminal' ? tab.parentTabId : tab.id + const currentId = read.localTabId(hostTabId) + const alreadyPresent = + read.hasTab(currentId) || + (tab.type === 'agent-session' && read.hasEntity('agent-session', tab.sessionId)) + return [ + { + hostTabId, + intent, + placement: { + ...intent.placement, + ...(intent.placement.afterTabId + ? { + afterTabId: read.localTabId(intent.placement.afterTabId) + } + : {}) + }, + activate: + intent.activate && + (!alreadyPresent || intent.viewerSelection !== undefined) && + read.viewerSelectionCurrent(intent.viewerSelection) + } + ] + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts index 7e549997c665..5477d915d281 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts @@ -1,5 +1,6 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { + SnapshotOpenSelectionOffer, WebSessionTabsBatchContext, WebSessionTabsSnapshotApplyOptions, WebSessionTabsSyncState @@ -43,8 +44,19 @@ export function createWebSessionTabsSnapshotPatch( snapshot: RuntimeMobileSessionTabsResult, environmentId: string ) { - return (state: WebSessionTabsSyncState, keys: Set) => - applyWebSessionTabsSnapshot(state, snapshot, environmentId, undefined, undefined, keys) + return ( + state: WebSessionTabsSyncState, + keys: Set, + offerOpenSelection?: (offer: SnapshotOpenSelectionOffer) => void + ) => + applyWebSessionTabsSnapshot( + state, + snapshot, + environmentId, + undefined, + { offerOpenSelection }, + keys + ) } export function applyWebSessionTabsSnapshots( @@ -135,7 +147,8 @@ export function decideWebSessionTabsSnapshotOperations( export function applyWebSessionTabsSnapshotOperations( state: WebSessionTabsSyncState, operations: readonly DecidedWebSessionTabsSnapshotOperation[], - changedAgentPaneKeys?: Set + changedAgentPaneKeys?: Set, + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { let nextState = state let mergedPatch: Partial = {} @@ -148,7 +161,7 @@ export function applyWebSessionTabsSnapshotOperations( snapshot, environmentId, undefined, - undefined, + options, changedAgentPaneKeys ) if (patch === nextState) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index c9ff465fb67f..1ba5ff10479b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -19,6 +19,7 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { OpenFile } from '../../store/slices/editor' import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' export const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 @@ -60,7 +61,13 @@ export type SessionTabsRuntimeHistory = RetiredValueHistory /** Bounded retired-epoch history prevents sibling streams’ delayed frames rolling back accepted replacements after restart. */ export type SessionTabsPublicationEpochHistory = RetiredValueHistory +export type SnapshotOpenSelectionOffer = ( + state: WebSessionTabsSyncState, + patch: Partial +) => Partial + export type WebSessionTabsSnapshotApplyOptions = { + offerOpenSelection?: (offer: SnapshotOpenSelectionOffer) => void contentScope?: 'all' | 'agent-session' preserveLocalLayout?: boolean terminalPtyMode?: 'local' | 'remote' @@ -230,6 +237,19 @@ export type WebSessionTabsSyncState = Pick< Partial< Pick< AppState, + | 'activeWorkspaceExecutionHostId' + | 'activeWorkspaceKey' + | 'activePendingCreationId' + | 'activeView' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'isNavigatingHistory' + | 'closedTerminalTabTombstonesByTabId' + | 'pairedTerminalTabCloseIntentsByEnvironment' + | 'acknowledgedAgentsByPaneKey' + | 'activityClearedAtByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'agentConversationsByPaneKey' | 'automaticAgentResumeClaimsByTabId' // Client draft proves dirty-flag ownership (#21392); without one, follow host save state. | 'editorDrafts' @@ -239,7 +259,8 @@ export type WebSessionTabsSyncState = Pick< | 'pendingStartupByTabId' | 'runtimePaneTitlesByTabId' > - > + > & + Partial> export type WebSessionTabsBatchRecordKey = | 'activeBrowserTabIdByWorktree' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 682ce6064dcb..ff9840710026 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -1,3 +1,10 @@ +import { applyOpenDestinationSelection } from './open-placement-selection' +import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' +import { + observeSwitchProjections, + protectSwitchProjections +} from '@/lib/conversation-switch-projection' import { markStructuredAgentSessionLaunchesPublished, publishedStructuredSessions @@ -20,6 +27,7 @@ import { agentStatusEntryEqual } from './state-equality-core' import { HOST_WORKING_CLIENT_BOUNDARY_LIMIT, hostWorkingClientBoundaryByPaneKey, + type SnapshotOpenSelectionOffer, type WebSessionTabsSyncState } from './state' import { reportVisibleTabPickToHost } from '../web-session-visible-tab-pick-report' @@ -29,11 +37,58 @@ import { type HostSessionMirrorSettle } from './mirror-settle' +export function snapshotOpenSelectionRecords( + state: WebSessionTabsSyncState, + worktreeId: string, + surface: Pick +): Pick { + return { + activeTabIdByWorktree: { ...state.activeTabIdByWorktree, [worktreeId]: surface.activeTabId }, + activeTabTypeByWorktree: { + ...state.activeTabTypeByWorktree, + [worktreeId]: surface.activeTabType + } + } +} + +/** The ordinary snapshot selection writer also handles an addressed publication/focus offer. */ +export function selectSessionTabForViewer( + worktreeId: string, + tabId: string, + viewerSelection: string, + environmentId = runtimeEnvironmentIdOrLocalUnlessTwin(useAppStore.getState(), worktreeId) ?? + LOCAL_STRUCTURED_SESSION_OWNER +): boolean { + let selected = false + applyWebSessionTabsStorePatch( + (_state, _keys, offer) => { + offer((state, patch) => + applyOpenDestinationSelection( + state, + patch, + worktreeId, + environmentId, + tabId, + viewerSelection, + true, + () => { + selected = true + } + ) + ) + return {} + }, + { frames: [] } + )() + return selected +} + /** Commit a reconciliation patch and return a receipt for its host evidence. */ export function applyWebSessionTabsStorePatch( buildPatch: ( state: AppState, - changedAgentPaneKeys: Set + changedAgentPaneKeys: Set, + offerOpenSelection: (offer: SnapshotOpenSelectionOffer) => void ) => WebSessionTabsSyncState | Partial, hostMirrorVerdict: HostSessionMirrorPatchVerdict, agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], @@ -58,7 +113,18 @@ export function applyWebSessionTabsStorePatch( state: AppState, changedAgentPaneKeys: Set ): WebSessionTabsSyncState | Partial => { - const patch = buildPatch(state, changedAgentPaneKeys) + for (const snapshot of agentStatusSnapshots + ? Array.isArray(agentStatusSnapshots) + ? agentStatusSnapshots + : [agentStatusSnapshots] + : []) { + observeSwitchProjections(state, snapshot, () => {}) + } + const offers: SnapshotOpenSelectionOffer[] = [] + let patch = protectSwitchProjections( + state, + buildPatch(state, changedAgentPaneKeys, (offer) => offers.push(offer)) + ) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') if (agentStatusSnapshots) { const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey @@ -181,6 +247,9 @@ export function applyWebSessionTabsStorePatch( } } } + for (const offer of offers) { + patch = offer(state, patch) + } patchCommitted = true return patch } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts index 82d20206d892..a5c11e99418a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -81,35 +81,41 @@ export function buildMirroredAgentTabs( const assignedIds = new Set() const replacementTabs = new Map() const replacementIds = new Set() + const currentAgentTabs = new Map() + for (const tab of currentUnifiedTabs) { + if (tab.contentType === 'agent-session') { + const indexed = currentAgentTabs.get(tab.entityId) ?? { tabs: [], nextAvailable: 0 } + indexed.tabs.push(tab) + currentAgentTabs.set(tab.entityId, indexed) + } + } + const firstUnreplaced = (sessionId: string): Tab | undefined => { + const indexed = currentAgentTabs.get(sessionId) + if (!indexed) { + return undefined + } + // Exclusions only grow during this apply; retain original candidate order, including duplicates. + while ( + indexed.nextAvailable < indexed.tabs.length && + replacementIds.has(indexed.tabs[indexed.nextAvailable]!.id) + ) { + indexed.nextAvailable++ + } + return indexed.tabs[indexed.nextAvailable] + } for (const tab of agentTabs) { if (!tab.replacesSessionId) { continue } const existing = - currentUnifiedTabs.find( - (candidate) => - candidate.contentType === 'agent-session' && candidate.entityId === tab.sessionId - ) ?? - currentUnifiedTabs.find( - (candidate) => - !replacementIds.has(candidate.id) && - candidate.contentType === 'agent-session' && - candidate.entityId === tab.replacesSessionId - ) + currentAgentTabs.get(tab.sessionId)?.tabs[0] ?? firstUnreplaced(tab.replacesSessionId) if (existing) { replacementTabs.set(tab.sessionId, existing) replacementIds.add(existing.id) } } return agentTabs.map((tab, index) => { - const existing = - replacementTabs.get(tab.sessionId) ?? - currentUnifiedTabs.find( - (candidate) => - !replacementIds.has(candidate.id) && - candidate.contentType === 'agent-session' && - candidate.entityId === tab.sessionId - ) + const existing = replacementTabs.get(tab.sessionId) ?? firstUnreplaced(tab.sessionId) const launch = readStructuredAgentLaunchRecord(tab.sessionId) const address = executionHostId === 'local' && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts index 7bb5eafb9baa..b95128cb92d5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts @@ -15,8 +15,10 @@ export function applyVisibilityResumeRepairs( } const decided = decideWebSessionTabsSnapshotOperations(operations) const settle = applyWebSessionTabsStorePatch( - (state, changedAgentPaneKeys) => - applyWebSessionTabsSnapshotOperations(state, decided, changedAgentPaneKeys), + (state, changedAgentPaneKeys, offerOpenSelection) => + applyWebSessionTabsSnapshotOperations(state, decided, changedAgentPaneKeys, { + offerOpenSelection + }), { frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, diff --git a/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts index 532f3d493f28..d66d6957aa3a 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts @@ -10,10 +10,16 @@ type HeldWorkspacesStore = { } /** Which workspaces frames draw: ones the window holds that no paired server mirrors. */ -export function framesDrawWorkspace(state: AppState): (key: string) => boolean { +export function framesDrawWorkspace( + state: AppState, + openedWorkspaces?: ReadonlySet +): (key: string) => boolean { const mirrored = pairedServerMirrors(state) return (key) => - (key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && !mirrored(key) + (key in state.groupsByWorktree || + key in state.unifiedTabsByWorktree || + openedWorkspaces?.has(key) === true) && + !mirrored(key) } /** Notify first visits to main-only workspaces; no later frame is guaranteed to trigger drawing. */ diff --git a/src/renderer/src/runtime/workspace-layout-frame-flush-plan.ts b/src/renderer/src/runtime/workspace-layout-frame-flush-plan.ts new file mode 100644 index 000000000000..7575810c29d0 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-flush-plan.ts @@ -0,0 +1,70 @@ +import type { AppState } from '@/store/types' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' +import type { PendingWorkspaceChanges } from './workspace-layout-frame-pending' +import type { LayoutFrameSource } from './layout-frame-stream' +import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' +import { frameOpenPlacements } from './workspace-layout-frame-open-selection' +import { liveTitleUpdates } from './workspace-layout-frame-changes' + +/** Classify and take accumulated frame work before the ordinary store writer builds it. */ +export function planLayoutFrameFlush( + state: AppState, + cache: WorkspaceLayoutCache, + pending: Map, + shown: Set, + source: LayoutFrameSource +) { + const owner = + source.target.kind === 'environment' + ? source.target.environmentId + : LOCAL_STRUCTURED_SESSION_OWNER + const drawn = source.draws(state) + const changed: string[] = [] + const fullBuilt: string[] = [] + const workspaceBuilt: string[] = [] + const rebound = new Map>() + const titleUpdates: TerminalTabTitleUpdate[] = [] + for (const [key, layout] of Object.entries(cache)) { + const changes = pending.get(key) + const opensHere = + !drawn(key) && + frameOpenPlacements(state, layout, owner).some((entry) => entry.activate) && + source.draws(state, new Set([key]))(key) + if (!drawn(key) && !opensHere) { + // Frames the window did not draw: its next draw takes the full build. + if (changes) { + shown.delete(key) + } + continue + } + if (shown.has(key) && !changes) { + continue + } + changed.push(key) + if (!shown.has(key)) { + fullBuilt.push(key) + } else if (changes?.rebuild) { + workspaceBuilt.push(key) + } else if (changes) { + titleUpdates.push( + ...liveTitleUpdates( + changes.titled, + layout, + (tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId + ) + ) + if (changes.rebound.size > 0) { + rebound.set(key, changes.rebound) + } + } + } + pending.clear() + // A workspace main stopped publishing keeps what the window shows; main has nothing to say. + for (const key of shown) { + if (!(key in cache)) { + shown.delete(key) + } + } + return { changed, fullBuilt, workspaceBuilt, rebound, titleUpdates } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-open-selection.ts b/src/renderer/src/runtime/workspace-layout-frame-open-selection.ts new file mode 100644 index 000000000000..7207861bbdb9 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-open-selection.ts @@ -0,0 +1,69 @@ +import type { AppState } from '@/store/types' +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' +import type { SnapshotOpenPlacement } from './web-session-tabs-sync/open-placement' +import { createOpenPlacementRead } from './web-session-tabs-sync/open-placement-read' +import { applySnapshotOpenSelection } from './web-session-tabs-sync/open-placement-selection' + +/** Frames already carry main's applied layout; only original-view selection remains window-owned. */ +export function frameOpenSelectionPatch( + before: AppState, + patch: Partial, + cache: WorkspaceLayoutCache, + keys: readonly string[], + target: RuntimeClientTarget, + consume = false, + onSelection?: (patch: Partial) => void +): Partial { + const owner = + target.kind === 'environment' ? target.environmentId : LOCAL_STRUCTURED_SESSION_OWNER + let next = patch + for (const key of keys) { + const layout = cache[key] + if (!layout) { + continue + } + const placements = frameOpenPlacements(before, layout, owner) + next = applySnapshotOpenSelection( + before, + next, + key, + owner, + placements, + new Map(layout.tabs.map((tab) => [tab.id, tab.id])), + consume, + onSelection + ) + } + return next +} + +/** Only an admitted explicit open asks the existing draw owner to materialize an unheld workspace. */ +export function frameOpenPlacements( + before: AppState, + layout: PublishedWorkspaceLayout, + owner: string +): SnapshotOpenPlacement[] { + let read: ReturnType | undefined + return layout.tabs.flatMap((tab) => { + const intent = tab.openPlacement + if (!intent?.activate) { + return [] + } + read ??= createOpenPlacementRead(before, layout.worktreeId, owner) + if (!read.viewerSelectionCurrent(intent.viewerSelection) || !read.sourceCurrent(intent)) { + return [] + } + const alreadyPresent = read.hasTab(tab.id) || read.hasEntity(tab.kind, tab.entityId) + return [ + { + hostTabId: tab.id, + intent, + placement: intent.placement, + activate: !alreadyPresent || intent.viewerSelection !== undefined + } + ] + }) +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-pending.ts b/src/renderer/src/runtime/workspace-layout-frame-pending.ts index 2d706b24a7f9..b6b8f93a78ee 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-pending.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-pending.ts @@ -2,7 +2,10 @@ import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' -import type { WorkspaceLayoutRecordsFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import type { + WorkspaceLayoutChangeFrame, + WorkspaceLayoutRecordsFrame +} from '../../../shared/workspace-layout/workspace-layout-stream-frames' import type { PublishedPane, PublishedTab, @@ -123,3 +126,27 @@ export function notePendingRecordsFrame( } } } + +/** A stream snapshot/whole workspace redraws; records accumulate against their prior cache. */ +export function noteReceivedLayoutFrame( + pending: Map, + shown: Set, + before: WorkspaceLayoutCache, + after: WorkspaceLayoutCache, + frame: WorkspaceLayoutChangeFrame, + snapshotReceived: boolean +): void { + switch (frame.type) { + case 'snapshot': + if (snapshotReceived) { + shown.clear() + } + return + case 'workspace': + case 'removed': + shown.delete(frame.key) + return + case 'records': + notePendingRecordsFrame(pending, shown, before, after, frame) + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-startup.ts b/src/renderer/src/runtime/workspace-layout-frame-startup.ts new file mode 100644 index 000000000000..1658e6edf7e4 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-startup.ts @@ -0,0 +1,18 @@ +import { useAppStore } from '@/store' +import { + openWorkspaceLayoutFrameSync, + type WorkspaceLayoutFrameSync +} from './workspace-layout-frame-sync' +import { provideLayoutFrameTerminalPanes } from './layout-frame-terminal-panes' + +let startupFrames: WorkspaceLayoutFrameSync | null = null + +/** After restore: the window's one layout stream shows the frames' workspaces from now on. */ +export function showStartupLayoutFrames(): void { + // Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it. + if (!startupFrames) { + startupFrames = openWorkspaceLayoutFrameSync(useAppStore) + provideLayoutFrameTerminalPanes(startupFrames) + } + startupFrames.start() +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index cf5fdaf884cf..db2f7abbad2a 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -1,6 +1,19 @@ +import { + beginViewerSelectionRequest, + disposeViewerSelection, + viewerSelectionOutcome +} from '@/lib/viewer-selection-request' +import { agentSessionSwitchDeadline } from '../../../shared/agent-session-switch-execution' +import { + captureSwitchProjection, + retireSwitchProjection, + disposeSwitchProjections +} from '@/lib/conversation-switch-projection' +import { noteUserWorkspaceSwitch } from '@/lib/workspace-switch-user-act' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PublishedTab } from '../../../shared/workspace-layout/workspace-layout-published' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' import { @@ -9,6 +22,8 @@ import { } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' import { + addWorkspace, + leaf, emptySession, GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' @@ -29,6 +44,8 @@ import { mainLayoutFrameSender, mainLoadedLayouts } from './main-loaded-layout-frames.test-fixture' +import { countOpenPlacementWork } from './open-placement-work-counter.test-fixture' +import * as frameFlushPlan from './workspace-layout-frame-flush-plan' import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -118,6 +135,8 @@ beforeEach(() => { check = installLayoutFrameApplyCheck() }) afterEach(() => { + disposeViewerSelection() + disposeSwitchProjections() endLayoutFramePositions() check.uninstall() expect(check.failures).toEqual([]) @@ -432,3 +451,231 @@ describe('the window drawn from layout frames', () => { sync.close() }) }) + +it.each([false, true])( + 'selects once after natural retirement and a real coalesced frame; later re-click=%s', + async (navigate) => { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'G', + activeTabId: 'A', + recent: ['C'], + tabs: [ + { + id: 'A', + entityId: 'canonical-A', + kind: 'agent-session', + entry: { agentSessionAgent: 'codex' } + }, + { id: 'B', leaves: [[leaf(1), 'pty-B']] }, + { id: 'C', leaves: [[leaf(2), 'pty-C']] } + ] + } + ]) + const { store, sync, layouts, frames } = await bootWithFrames('local', session) + const deadline = agentSessionSwitchDeadline(Date.now) + sync.start() + await flushMicrotasks() + try { + const request = beginViewerSelectionRequest('frame-switch', deadline) + request.accept() + const proposal = { + invocationId: 'frame-switch', + generation: 'generation', + sessionId: 'canonical-A', + tabId: 'A', + phase: 'prepare' as const + } + captureSwitchProjection(store.getState(), GIT_KEY, proposal, () => {}) + const retired = retireSwitchProjection(store.getState(), { ...proposal, phase: 'retire' }) + expect(retired).not.toBeNull() + store.setState(retired!) + const siblingTree = store.getState().terminalLayoutsByTabId.B + const fallback = store.getState().groupsByWorktree[GIT_KEY][0].activeTabId + expect(fallback).toBe('C') + if (navigate) { + // The ordinary explicit tab ingress counts even a re-click at the automatic fallback. + noteUserWorkspaceSwitch(GIT_KEY) + store.getState().activateTab('C', { worktreeId: GIT_KEY }) + } + const destination = structuredClone(layouts[GIT_KEY]!) + destination.tabs = destination.tabs.filter((tab) => tab.id !== 'A') + const published: PublishedTab = { + id: 'D', + entityId: 'D', + kind: 'terminal', + createdAt: 123, + customTitle: null, + color: null, + executionHostId: 'local', + terminal: { + root: { type: 'leaf', leafId: leaf(3) }, + panes: [{ leafId: leaf(3), ptyId: 'pty-D' }] + }, + openPlacement: { + invocationId: 'frame-switch', + generation: 'generation', + placement: { groupId: 'G', index: 0 }, + activate: true, + viewerSelection: request.token + } + } + destination.tabs.unshift(published) + destination.groups[0].tabIds = ['D', 'B', 'C'] + stream.onFrame!(frames.records(GIT_KEY, destination)) + await flushMicrotasks() + expect(store.getState().groupsByWorktree[GIT_KEY][0].tabOrder).toEqual(['D', 'B', 'C']) + expect(store.getState().groupsByWorktree[GIT_KEY][0].activeTabId).toBe(navigate ? 'C' : 'D') + expect(store.getState().terminalLayoutsByTabId.B).toEqual(siblingTree) + expect(viewerSelectionOutcome(request.token)).toBe(navigate ? 'superseded' : 'fulfilled') + stream.onFrame!(frames.snapshot({ [GIT_KEY]: destination })) + await flushMicrotasks() + expect(store.getState().groupsByWorktree[GIT_KEY][0].activeTabId).toBe(navigate ? 'C' : 'D') + } finally { + deadline.dispose() + sync.close() + } + } +) + +it.each([10, 100])( + 'bounds retained undrawn metadata discovery on an unrelated coalesced frame flush (%i destinations)', + async (count) => { + const visible = addWorkspace(emptySession(), GIT_KEY, [ + { id: 'visible', tabs: [{ id: 'C', leaves: [[leaf(1), 'pty-C']] }] } + ]) + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: visible }]) + const session = structuredClone(visible) + const undrawnKeys = ['folder:undrawn-one', 'folder:undrawn-two'] + for (const [workspaceIndex, key] of undrawnKeys.entries()) { + addWorkspace(session, key, [ + { + id: `undrawn-${workspaceIndex}`, + tabs: Array.from( + { length: count / 2 }, + (_, i): { id: string; leaves: [string, string][] } => { + const n = (workspaceIndex * count) / 2 + i + return { + id: `D-${n}`, + leaves: [ + [`10000000-0000-4000-8000-${String(n + 2).padStart(12, '0')}`, `pty-D-${n}`] + ] + } + } + ) + } + ]) + } + const layouts = mainLoadedLayouts('local', session) + const deadline = agentSessionSwitchDeadline(Date.now) + const sync = openWorkspaceLayoutFrameSync(store) + let counter: ReturnType | undefined + let restorePlan: (() => void) | undefined + try { + const tokens: string[] = [] + for (const key of undrawnKeys) { + for (const tab of layouts[key].tabs) { + const request = beginViewerSelectionRequest(`cached-${tab.id}`, deadline) + request.accept() + tokens.push(request.token) + tab.openPlacement = { + invocationId: `cached-${tab.id}`, + generation: `generation-${tab.id}`, + placement: { groupId: layouts[key].groups[0].id, index: 0 }, + activate: true, + viewerSelection: request.token, + source: { + worktreeId: key, + tabId: `absent-source-${tab.id}`, + leafId: leaf(1), + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: `source-pty-${tab.id}`, + incarnationId: null + } + } + } + } + const visibleRequest = beginViewerSelectionRequest('visible-choice', deadline) + visibleRequest.accept() + // An unchanged startup patch is discarded before the selection writer; publish a real change. + layouts[GIT_KEY].tabs[0].customTitle = 'initial publication' + layouts[GIT_KEY].tabs[0].openPlacement = { + invocationId: 'visible-choice', + generation: 'visible-generation', + placement: { groupId: 'visible', index: 0 }, + activate: true, + viewerSelection: visibleRequest.token + } + const frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot(layouts)) + sync.start() + await flushMicrotasks() + expect(viewerSelectionOutcome(visibleRequest.token)).toBe('fulfilled') + expect(tokens.map(viewerSelectionOutcome)).toEqual( + Array.from({ length: count }, () => 'superseded') + ) + for (const key of undrawnKeys) { + expect(store.getState().unifiedTabsByWorktree[key]).toBeUndefined() + expect(store.getState().tabsByWorktree[key]).toBeUndefined() + } + counter = countOpenPlacementWork() + const plan = vi.spyOn(frameFlushPlan, 'planLayoutFrameFlush') + restorePlan = () => plan.mockRestore() + const changed = structuredClone(layouts[GIT_KEY]) + changed.tabs[0].customTitle = 'first update' + stream.onFrame!(frames.records(GIT_KEY, changed)) + changed.tabs[0].customTitle = 'second update' + stream.onFrame!(frames.records(GIT_KEY, changed)) + await flushMicrotasks() + expect(plan).toHaveBeenCalledTimes(1) + expect(plan.mock.results[0].value).toMatchObject({ fullBuilt: [], workspaceBuilt: [GIT_KEY] }) + expect(counter.builtKeys).toEqual([[GIT_KEY]]) + expect(counter.visits).toEqual([ + ...undrawnKeys.map((worktreeId) => ({ pass: 'discovery', worktreeId, rows: count / 2 })), + { pass: 'writer', worktreeId: GIT_KEY, rows: 2 } + ]) + expect(counter.reads).toEqual([ + ...undrawnKeys.map((worktreeId) => ({ + pass: 'discovery', + worktreeId, + tabRows: 0, + terminalRows: 0, + snapshotRows: 0, + mappingCopies: 0, + mappingRows: 0, + sourceChecks: 0, + viewerChecks: count / 2, + destinationLookups: 0 + })), + { + pass: 'writer', + worktreeId: GIT_KEY, + tabRows: 2, + terminalRows: 1, + snapshotRows: 0, + mappingCopies: 0, + mappingRows: 0, + sourceChecks: 0, + viewerChecks: 1, + destinationLookups: 0 + } + ]) + expect(counter.fingerprints()).toBe(0) + expect(store.getState().groupsByWorktree[GIT_KEY][0].activeTabId).toBe('C') + expect(store.getState().tabsByWorktree[GIT_KEY][0].customTitle).toBe('second update') + for (const key of undrawnKeys) { + expect(store.getState().unifiedTabsByWorktree[key]).toBeUndefined() + expect(store.getState().tabsByWorktree[key]).toBeUndefined() + } + expect(viewerSelectionOutcome(visibleRequest.token)).toBe('fulfilled') + } finally { + restorePlan?.() + counter?.stop() + sync.close() + deadline.dispose() + } + } +) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index fdb409b7d03e..a890f68c592a 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -1,7 +1,7 @@ // Layout-owner windows draw main’s locally owned, held workspaces; paired servers remain mirror-owned. Local tab changes stay drawn until main answers (layout-owner-drawn-tab-holds). Records frames apply binding/title deltas directly and rebuild other changed workspaces from store. Startup/reopen/new/whole workspaces use full save/Loader builds (E2a/E2b). +import { frameOpenSelectionPatch } from './workspace-layout-frame-open-selection' import { showFramePatch, withMovedTabsSelected } from '@/store/slices/tabs/moved-tab-selection' -import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache' @@ -11,9 +11,8 @@ import { layoutHoldsBoundPane, onWorkspaceStartsDrawing } from './workspace-layout-frame-drawn-workspaces' -import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' -import { liveTitleUpdates } from './workspace-layout-frame-changes' +import { planLayoutFrameFlush } from './workspace-layout-frame-flush-plan' import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees' import { framePaneFactsShown, showFramePaneStarts } from './workspace-layout-frame-pane-starts' import { @@ -26,15 +25,12 @@ import { heldTabGroupFocus } from './held-tab-group-follow' import { shownContentRekeyPatch } from './layout-owner-content-rekeys' import { provideLayoutFrameWorkspaces } from './layout-frame-workspaces' import { - notePendingRecordsFrame, + noteReceivedLayoutFrame, pendingWorkspaceChanges, type PendingWorkspaceChanges } from './workspace-layout-frame-pending' import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer' -import { - provideLayoutFrameTerminalPanes, - type LayoutFrameTerminalQueries -} from './layout-frame-terminal-panes' +import type { LayoutFrameTerminalQueries } from './layout-frame-terminal-panes' import { isTabPaneTreeHeld, onTabPaneTreeRedraw, @@ -54,7 +50,7 @@ import { type FrameSyncStore = { getState: () => AppState - setState: (patch: Partial) => void + setState: (patch: Partial | ((state: AppState) => Partial)) => void subscribe: (listener: (state: AppState) => void) => () => void } @@ -88,27 +84,11 @@ export function openWorkspaceLayoutFrameSync( /** The moved tabs' selections this flush's writes took. */ let selectionsTaken: MovedTabSelection[] = [] - const show = (patch: LayoutFrameBuildPatch): void => { - selectionsTaken.push(...showFramePatch(store, patch)) - } - - // Each change against the cache's copy it applies to, before the frame replaces it. - const noteFrame = (before: WorkspaceLayoutCache, frame: WorkspaceLayoutChangeFrame): void => { - switch (frame.type) { - case 'snapshot': - // A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile. - if (snapshotReceived) { - shown.clear() - } - return - case 'workspace': - case 'removed': - // An older host's whole workspace: only a full build shows it. - shown.delete(frame.key) - return - case 'records': - notePendingRecordsFrame(pending, shown, before, cache, frame) - } + const show = ( + patch: LayoutFrameBuildPatch | ((current: AppState) => LayoutFrameBuildPatch), + select?: (current: AppState, accepted: Partial) => Partial + ): void => { + selectionsTaken.push(...showFramePatch(store, patch, select)) } // Pane trees show at once; only a tab the window's own pane command holds waits. @@ -131,7 +111,6 @@ export function openWorkspaceLayoutFrameSync( return } const state = store.getState() - const drawn = source.draws(state) const rowFieldsTaken = new Map>() const holds: LayoutFrameBuildHolds = { ...FRAME_BUILD_HOLDS, @@ -144,55 +123,34 @@ export function openWorkspaceLayoutFrameSync( return fields } } - const changed: string[] = [] - const fullBuilt: string[] = [] - const workspaceBuilt: string[] = [] - const rebound = new Map>() - const titleUpdates: TerminalTabTitleUpdate[] = [] - for (const [key, layout] of Object.entries(cache)) { - const changes = pending.get(key) - if (!drawn(key)) { - // Frames the window did not draw: its next draw takes the full build. - if (changes) { - shown.delete(key) - } - continue - } - if (shown.has(key) && !changes) { - continue - } - changed.push(key) - if (!shown.has(key)) { - fullBuilt.push(key) - } else if (changes?.rebuild) { - workspaceBuilt.push(key) - } else if (changes) { - titleUpdates.push( - ...liveTitleUpdates( - changes.titled, - layout, - (tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId - ) - ) - if (changes.rebound.size > 0) { - rebound.set(key, changes.rebound) - } - } - } - pending.clear() - // A workspace main stopped publishing keeps what the window shows; main has nothing to say. - for (const key of shown) { - if (!(key in cache)) { - shown.delete(key) - } - } + const { changed, fullBuilt, workspaceBuilt, rebound, titleUpdates } = planLayoutFrameFlush( + state, + cache, + pending, + shown, + source + ) // One update: a move's or rename's content rekeys with the frame that shows it. const rekeyed = shownContentRekeyPatch(state, cache) const base: AppState = { ...state, ...rekeyed } - show({ + const membership = { ...rekeyed, ...layoutFrameFlushPatch(base, cache, { fullBuilt, workspaceBuilt, rebound }, holds) - }) + } + let openSelectionTaken: Partial = {} + show(membership, (current, accepted) => + frameOpenSelectionPatch( + current, + accepted, + cache, + [...fullBuilt, ...workspaceBuilt], + source.target, + true, + (selection) => { + openSelectionTaken = { ...openSelectionTaken, ...selection } + } + ) + ) if (titleUpdates.length > 0) { store.getState().updateTabTitles(titleUpdates) } @@ -222,7 +180,13 @@ export function openWorkspaceLayoutFrameSync( workspaceBuilt, rowFieldsTaken, held: holds.held, - selectTakenMovedTabs: (state) => withMovedTabsSelected(state, selectionsTaken) + selectTakenMovedTabs: (built) => { + const moved = withMovedTabsSelected(built, selectionsTaken) + return { + ...moved, + ...openSelectionTaken + } + } }) followHeldTabs() } @@ -248,7 +212,7 @@ export function openWorkspaceLayoutFrameSync( const receive = (frame: WorkspaceLayoutChangeFrame): void => { const before = cache cache = applyLayoutFrame(cache, frame) - noteFrame(before, frame) + noteReceivedLayoutFrame(pending, shown, before, cache, frame, snapshotReceived) if (frame.type === 'snapshot') { snapshotReceived = true live = started @@ -331,15 +295,3 @@ const FRAME_BUILD_HOLDS: Omit = { keepsTerminalLayout: isTabPaneTreeHeld, held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } } - -let startupFrames: WorkspaceLayoutFrameSync | null = null - -/** After restore: the window's one layout stream shows the frames' workspaces from now on. */ -export function showStartupLayoutFrames(): void { - // Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it. - if (!startupFrames) { - startupFrames = openWorkspaceLayoutFrameSync(useAppStore) - provideLayoutFrameTerminalPanes(startupFrames) - } - startupFrames.start() -} diff --git a/src/renderer/src/store/agent-conversation-retention.ts b/src/renderer/src/store/agent-conversation-retention.ts index e132b536f325..414ff46c757a 100644 --- a/src/renderer/src/store/agent-conversation-retention.ts +++ b/src/renderer/src/store/agent-conversation-retention.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from './types' import type { StoreSetArgs } from './react-commit-cascade-write-probe' +import { protectedConversationPaneKeys } from '@/lib/pending-conversation-surface-switches' import { classifyPaneKeyLiveness, MAX_LIVE_AGENT_STATUSES @@ -18,7 +19,8 @@ function cappedConversations(state: AppState, previous: Conversations): Conversa let evicted: string[] = [] if (keys.length > MAX_LIVE_AGENT_STATUSES) { const classify = classifyPaneKeyLiveness(state) - const eligible = keys.filter((key) => classify(key) !== 'live') + const protectedKeys = protectedConversationPaneKeys(entries) + const eligible = keys.filter((key) => classify(key) !== 'live' && !protectedKeys.has(key)) evicted = eligible.slice(0, Math.max(0, eligible.length - MAX_LIVE_AGENT_STATUSES)) } if (evicted.length === 0 && keys.every((key, index) => key === incoming[index])) { diff --git a/src/renderer/src/store/github/check-details-failure-mapping.test.ts b/src/renderer/src/store/github/check-details-failure-mapping.test.ts index 20fa95587a2c..183f918811fe 100644 --- a/src/renderer/src/store/github/check-details-failure-mapping.test.ts +++ b/src/renderer/src/store/github/check-details-failure-mapping.test.ts @@ -152,7 +152,9 @@ for (const surface of surfaces) { }, timeoutMs: 30_000, expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: undefined + expectedEnvironmentRuntimeId: undefined, + dispatchObservation: undefined, + observeDispatch: false }, expect.any(Object) ) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-change-publication.ts b/src/renderer/src/store/slices/agent-status/agent-status-change-publication.ts new file mode 100644 index 000000000000..884a4e2e13ed --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/agent-status-change-publication.ts @@ -0,0 +1,84 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import { structuralValuesEqualIgnoringUndefined } from '../../../../../shared/structural-value-equality' +import type { AgentStatusChange, AgentStatusChangeListener } from './agent-status-contract' + +export function createAgentStatusChangePublication( + storeSet: Parameters>[0], + storeGet: Parameters>[1] +): { + commit: ( + patch: AppState | Partial, + before: AppState, + paneKeys: ReadonlySet + ) => void + subscribeAgentStatusChanges: (listener: AgentStatusChangeListener) => () => void +} { + const listeners = new Set() + const pendingChanges: AgentStatusChange[][] = [] + let committing = 0 + let notifying = false + const subscribeAgentStatusChanges = (listener: AgentStatusChangeListener): (() => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const flushChanges = (): void => { + if (committing || notifying) { + return + } + notifying = true + try { + for (const accepted of pendingChanges) { + if (!accepted?.length) { + continue + } + const currentListeners = [...listeners] + for (const listener of currentListeners) { + if (!listeners.has(listener)) { + continue + } + try { + listener(accepted) + } catch (error) { + console.error('Agent status observer failed', error) + } + } + } + } finally { + pendingChanges.length = 0 + notifying = false + } + } + const commit = ( + patch: AppState | Partial, + before: AppState, + paneKeys: ReadonlySet + ): void => { + if (!listeners.size) { + storeSet(patch, false) + return + } + const changes: AgentStatusChange[] = [] + const rows = patch.agentStatusByPaneKey ?? before.agentStatusByPaneKey + for (const paneKey of paneKeys) { + const previous = before.agentStatusByPaneKey[paneKey] + const current = rows[paneKey] + if (!structuralValuesEqualIgnoringUndefined(previous, current)) { + changes.push({ paneKey, previous, current }) + } + } + committing++ + pendingChanges.push(changes) + try { + storeSet(patch, false) + } finally { + // A middleware rejection before commit must never advertise accepted rows. + if (storeGet() === before) { + changes.length = 0 + } + committing-- + flushChanges() + } + } + return { commit, subscribeAgentStatusChanges } +} diff --git a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts index 08dbdb0f6da5..3963602c18f3 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts @@ -1,12 +1,11 @@ -import { applyAgentStatusBatchUpdate } from './agent-status-batch-update' import { projectAgentPaneConversation } from '../agent-pane-conversation' -import { structuralValuesEqualIgnoringUndefined } from '../../../../../shared/structural-value-equality' +import { createAgentStatusChangePublication } from './agent-status-change-publication' +import { applyAgentStatusBatchUpdate } from './agent-status-batch-update' import type { StateCreator } from 'zustand' import type { AppState } from '../../types' import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate, - AgentStatusChange, AgentStatusChangeListener } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' @@ -41,73 +40,11 @@ export function createAgentStatusRuntime( 'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus' > ): AgentStatusRuntime { - const listeners = new Set() - const pendingChanges: AgentStatusChange[][] = [] - let committing = 0 - let notifying = false + const { commit, subscribeAgentStatusChanges } = createAgentStatusChangePublication( + storeSet, + storeGet + ) let batchedPaneKeys: Set | null = null - const subscribeAgentStatusChanges = (listener: AgentStatusChangeListener): (() => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - } - const flushChanges = (): void => { - if (committing || notifying) { - return - } - notifying = true - try { - for (const accepted of pendingChanges) { - if (!accepted?.length) { - continue - } - const currentListeners = [...listeners] - for (const listener of currentListeners) { - if (!listeners.has(listener)) { - continue - } - try { - listener(accepted) - } catch (error) { - console.error('Agent status observer failed', error) - } - } - } - } finally { - pendingChanges.length = 0 - notifying = false - } - } - const commit = ( - patch: AppState | Partial, - before: AppState, - paneKeys: ReadonlySet - ): void => { - if (!listeners.size) { - storeSet(patch, false) - return - } - const changes: AgentStatusChange[] = [] - const rows = patch.agentStatusByPaneKey ?? before.agentStatusByPaneKey - for (const paneKey of paneKeys) { - const previous = before.agentStatusByPaneKey[paneKey] - const current = rows[paneKey] - if (!structuralValuesEqualIgnoringUndefined(previous, current)) { - changes.push({ paneKey, previous, current }) - } - } - committing++ - pendingChanges.push(changes) - try { - storeSet(patch, false) - } finally { - // A middleware rejection before commit must never advertise accepted rows. - if (storeGet() === before) { - changes.length = 0 - } - committing-- - flushChanges() - } - } let batchedAgentStatusState: AppState | null = null let batchedAgentStatusTouchedKeys: Set | null = null // Staged in-place mutations cannot signal changes by identity; count every accepted write. @@ -116,7 +53,6 @@ export function createAgentStatusRuntime( let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null let batchedAgentStatusFreshnessRequested = false const get = (): AppState => batchedAgentStatusState ?? storeGet() - let conversationDraft: AppState['agentConversationsByPaneKey'] | null = null const projectConversation: typeof projectAgentPaneConversation = (state, entry) => projectAgentPaneConversation(state, entry, (entries) => { diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts index 22105d6c894f..a143bbcc6ddd 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' @@ -177,6 +178,7 @@ export function createBrowserTabActions( }, openNewBrowserTabInActiveWorkspace: async (groupId, splitDirection) => { + noteViewerNavigation() const state = get() // Why: the invoking group owns its workspace; global selection may already point elsewhere. const worktreeId = diff --git a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts index 2b576c451ba8..18214fb916df 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -45,6 +46,7 @@ export function createMarkdownPreviewActions( > { return { openNewMarkdownInActiveWorkspace: async (groupId) => { + noteViewerNavigation() const created = await createUntitledFileInActiveWorkspace( get, groupId, diff --git a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts index 74d29b79d53a..be98c242a3ac 100644 --- a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts +++ b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts @@ -578,6 +578,8 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => { selector: 'env-1', expectedEnvironmentPairingRevision: undefined, expectedEnvironmentRuntimeId: undefined, + dispatchObservation: undefined, + observeDispatch: false, method: 'github.prCheckDetails', params: { repo: repoId, @@ -678,6 +680,8 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => { selector: 'env-1', expectedEnvironmentPairingRevision: undefined, expectedEnvironmentRuntimeId: undefined, + dispatchObservation: undefined, + observeDispatch: false, method: 'github.prCheckDetails', params: { repo: repoId, diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 2a955997fc13..ad7ff7d7c0c7 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -8,6 +8,8 @@ export type PaneForegroundAgentEvidence = 'process-read' | 'launch-record' export type PaneForegroundAgentEntry = { /** Recognized agent process in the pane's foreground; null when unknown. */ agent: TerminalAgent | null + /** Observed stopped job; independent of foreground byte routing. */ + suspendedAgent?: TerminalAgent /** Where `agent` came from. Only 'process-read' is a live read; absent counts as unconfirmed, * so a writer that omits it cannot make a sidebar row look process-backed. */ agentEvidence?: PaneForegroundAgentEvidence @@ -46,6 +48,7 @@ export const createPaneForegroundAgentSlice: StateCreator< if ( current && current.agent === entry.agent && + current.suspendedAgent === entry.suspendedAgent && current.agentEvidence === entry.agentEvidence && current.routingTrusted === entry.routingTrusted && current.routingRevoked === entry.routingRevoked && diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 70e33babea43..55979754568f 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { normalizeNativeChatAppearanceSettings } from '../../../../shared/native-chat/native-chat-appearance-settings' import type { StateCreator } from 'zustand' import type { AppState } from '../types' @@ -241,6 +242,7 @@ export const createSettingsSlice: StateCreator }, setActiveRuntimeEnvironmentPreference: async (environmentId) => { + noteViewerNavigation() const nextId = normalizeRuntimeEnvironmentId(environmentId) const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId) if (previousId === nextId) { diff --git a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts index 0e3769754703..5f44f9a74d95 100644 --- a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts +++ b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts @@ -13,7 +13,7 @@ import type { AppState } from '../types' type DrawnStore = { getState: () => AppState - setState: (patch: Partial) => void + setState: (patch: Partial | ((state: AppState) => Partial)) => void subscribe: (listener: (state: AppState) => void) => () => void } diff --git a/src/renderer/src/store/slices/tabs/moved-tab-selection.ts b/src/renderer/src/store/slices/tabs/moved-tab-selection.ts index 7fd2a2e1dd03..2714dd6d9b59 100644 --- a/src/renderer/src/store/slices/tabs/moved-tab-selection.ts +++ b/src/renderer/src/store/slices/tabs/moved-tab-selection.ts @@ -1,3 +1,4 @@ +import { protectSwitchProjections } from '@/lib/conversation-switch-projection' // Select moved tabs with the frame showing the move (L120); a later reply may already be superseded by another drag. import type { AppState } from '../../types' @@ -84,13 +85,26 @@ export function withMovedTabsSelected( /** Writes a frame's patch, with the selection of each moved tab it shows; returns those moves. */ export function showFramePatch( - store: { getState: () => AppState; setState: (patch: Partial) => void }, - patch: Partial + store: { + getState: () => AppState + setState: (patch: Partial | ((state: AppState) => Partial)) => void + }, + patch: Partial | ((current: AppState) => Partial), + select?: (current: AppState, accepted: Partial) => Partial ): MovedTabSelection[] { - if (Object.keys(patch).length === 0) { + if (typeof patch !== 'function' && Object.keys(patch).length === 0) { return [] } - const shown = withMovedTabSelections(store.getState(), patch) - store.setState(shown.patch) - return shown.taken + let taken: MovedTabSelection[] = [] + store.setState((current) => { + const shown = withMovedTabSelections( + current, + typeof patch === 'function' ? patch(current) : patch + ) + taken = shown.taken + const protectedPatch = protectSwitchProjections(current, shown.patch) + const next = select ? select(current, protectedPatch) : protectedPatch + return Object.keys(next).length === 0 ? current : next + }) + return taken } diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 57611166d12c..b07fbe64aa26 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,3 +1,4 @@ +import { endViewerSelectionForDestination } from '@/lib/viewer-selection-request' import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' @@ -45,6 +46,7 @@ export function createTabsCloseActions( return null } const { tab, worktreeId } = found + endViewerSelectionForDestination(worktreeId, tab.id) forgetDesktopCreatedServerTab(tab.id) forgetDesktopCreatedServerTab(tab.entityId) const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index c47d530519e4..e5b441128e06 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -1,3 +1,4 @@ +import { noteSwitchPresentationUse } from '@/lib/conversation-switch-projection' import { createBrowserUuid } from '@/lib/browser-uuid' import type { TabGroup } from '../../../../../shared/tab-types' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' @@ -24,6 +25,7 @@ export function createTabsDropActions( ): Pick { return { dropUnifiedTab: (tabId, target) => { + noteSwitchPresentationUse(tabId) let moved = false set((state) => { const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index ee078883aadf..7715da0a461d 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -1,3 +1,4 @@ +import { noteSwitchPresentationUse } from '@/lib/conversation-switch-projection' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { findTabAndWorktree, @@ -103,6 +104,7 @@ export function createTabsFocusActions( ), activateTab: (tabId, opts) => { + noteSwitchPresentationUse(tabId) const promoted: Tab[] = [] set((state) => { const scopedWorktreeId = opts?.worktreeId diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index d91ec04ed778..e625a0d3398b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -1,7 +1,8 @@ +import { noteSwitchPresentationUse } from '@/lib/conversation-switch-projection' import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' import type { TuiAgent } from '../../../../../shared/tui-agent' import type { AppState } from '../../types' -import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import type { TabsSliceGet, TabsSliceSet, TabsLabelActions } from './tabs-slice-contract' import { findTabAndWorktree, patchTab, updateGroup } from '../tab-group-state' import { applyTabOrderSortValues, @@ -24,20 +25,7 @@ function oppositeViewMode(mode: 'terminal' | 'chat'): 'terminal' | 'chat' { return mode === 'chat' ? 'terminal' : 'chat' } -export function createTabsLabelActions( - set: TabsSliceSet, - get: TabsSliceGet -): Pick< - TabsSlice, - | 'reorderUnifiedTabs' - | 'setTabLabel' - | 'setTabViewMode' - | 'toggleTabViewMode' - | 'setTabCustomLabel' - | 'setUnifiedTabColor' - | 'pinTab' - | 'unpinTab' -> { +export function createTabsLabelActions(set: TabsSliceSet, get: TabsSliceGet): TabsLabelActions { function setTabViewModeHere(tabId: string, mode: 'terminal' | 'chat'): void { set((state) => { const tabPatch = patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) @@ -160,6 +148,11 @@ export function createTabsLabelActions( return { reorderUnifiedTabs: (groupId, tabIds, opts) => { + if (opts?.recordInteraction !== false) { + for (const tabId of tabIds) { + noteSwitchPresentationUse(tabId) + } + } let reordered = false const changes: { worktreeId: string; from: string[]; to: string[] }[] = [] set((state) => { diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 112bc5173462..c745a19c76eb 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -1,3 +1,4 @@ +import { noteSwitchPresentationUse } from '@/lib/conversation-switch-projection' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { buildActiveSurfacePatch } from './tabs-surface' @@ -17,6 +18,7 @@ export function createTabsMoveActions( ): Pick { return { moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { + noteSwitchPresentationUse(tabId) let moved = false set((state) => { const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index 026c994e8dce..9f4b72b9b360 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -194,3 +194,15 @@ export type TabsSlice = { type TabsStateCreator = StateCreator export type TabsSliceSet = Parameters[0] export type TabsSliceGet = Parameters[1] + +export type TabsLabelActions = Pick< + TabsSlice, + | 'reorderUnifiedTabs' + | 'setTabLabel' + | 'setTabViewMode' + | 'toggleTabViewMode' + | 'setTabCustomLabel' + | 'setUnifiedTabColor' + | 'pinTab' + | 'unpinTab' +> diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index b2ee9f350a6e..13285cfc6374 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -1,3 +1,6 @@ +import type { TabGroup } from '../../../../shared/tab-types' +import { dedupeTabOrder } from '../../../../shared/workspace-layout/tab-order' +import { pushRecentTabId, sanitizeRecentTabIds } from './tab-group-state' import type { AppState } from '../types' type TerminalTabReconnectState = Pick< @@ -192,3 +195,33 @@ export function buildOrphanTerminalCleanupPatch( state.activeTabId && orphanTerminalIds.has(state.activeTabId) ? null : state.activeTabId } } + +/** Repair every group before adding a tab, including inactive/background creation. */ +export function removeOrphanTerminalsFromGroups( + groups: TabGroup[], + orphanTerminalIds: ReadonlySet +): TabGroup[] { + return orphanTerminalIds.size === 0 + ? groups + : groups.map((entry) => { + // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. + const tabOrder = dedupeTabOrder(entry.tabOrder).filter( + (tabId) => !orphanTerminalIds.has(tabId) + ) + const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) + const replacedActiveTabId = Boolean( + entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) + ) + const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null + const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId + return { + ...entry, + activeTabId, + tabOrder, + recentTabIds: + replacedActiveTabId && activeTabId + ? pushRecentTabId(recentTabIds, activeTabId) + : recentTabIds + } + }) +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts index 81de3888e343..a868c057d6e4 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts @@ -1,9 +1,11 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import { isSettingsNavigationTarget } from '../../../lib/settings-navigation-types' export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Partial { return { openSettingsPage: () => { + noteViewerNavigation() // Why: settings search is a transient filter; opening Settings shouldn't inherit hidden sections from last visit. get().setSettingsSearchQuery('') set((state) => ({ @@ -13,12 +15,13 @@ export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Parti state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView })) }, - closeSettingsPage: () => - set((state) => { - return { activeView: state.previousViewBeforeSettings } - }), + closeSettingsPage: () => { + noteViewerNavigation() + set((state) => ({ activeView: state.previousViewBeforeSettings })) + }, settingsNavigationTarget: null, openSettingsTarget: (target) => { + noteViewerNavigation() if (!isSettingsNavigationTarget(target)) { if (import.meta.env.DEV) { throw new TypeError('openSettingsTarget received an invalid navigation target') diff --git a/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts index 6faa5283c335..c527bbd104b6 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import { findPrevLiveNonTaskStackHistoryIndex } from '../worktree-nav-history' import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' @@ -32,6 +33,7 @@ export function createUiTaskActions(set: UISliceSet, get: UISliceGet): Partial { + noteViewerNavigation() if (options.recordTasksInteraction !== false) { const wasTasksPreviouslyInteracted = hasFeatureInteraction( get().featureInteractions, diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts index ccece2d27c83..897f4969333b 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -1,25 +1,30 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import { rewindHistoryIndexPastView } from '../worktree-nav-history' export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { return { openActivityPage: () => { + noteViewerNavigation() set((state) => ({ activeView: 'activity', previousViewBeforeActivity: state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView })) }, - closeActivityPage: () => - set((state) => ({ + closeActivityPage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeActivity - })), + })) + }, selectedAutomationId: null, setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), pendingAutomationRunNavigation: null, setPendingAutomationRunNavigation: (navigation) => set({ pendingAutomationRunNavigation: navigation }), openAutomationsPage: () => { + noteViewerNavigation() get().recordViewVisit('automations') set((state) => ({ activeView: 'automations', @@ -29,12 +34,15 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial - set((state) => ({ + closeAutomationsPage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeAutomations, worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'automations') - })), + })) + }, openSpacePage: () => { + noteViewerNavigation() get().recordFeatureInteraction?.('workspace-cleanup') if (typeof window !== 'undefined' && window.api?.workspaceSpace?.openWindow) { void window.api.workspaceSpace.openWindow() @@ -46,11 +54,14 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial - set((state) => ({ + closeSpacePage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeSpace - })), + })) + }, openSkillsPage: () => { + noteViewerNavigation() get().recordViewVisit('skills') set((state) => ({ activeView: 'skills', @@ -58,12 +69,15 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial - set((state) => ({ + closeSkillsPage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeSkills, worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'skills') - })), + })) + }, openSkillShare: (shareId) => { + noteViewerNavigation() get().recordViewVisit('skills') set((state) => ({ activeView: 'skills', @@ -74,6 +88,7 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial set({ pendingSkillShareId: null }), openSkillsSharedLinks: () => { + noteViewerNavigation() get().recordViewVisit('skills') set((state) => ({ activeView: 'skills', @@ -84,6 +99,7 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial set({ pendingSkillsSharedView: false }), openArtifactsPage: () => { + noteViewerNavigation() get().recordViewVisit('artifacts') set((state) => ({ activeView: 'artifacts', @@ -91,21 +107,27 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial - set((state) => ({ + closeArtifactsPage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeArtifacts, worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'artifacts') - })), - openMobilePage: () => - set((state) => ({ + })) + }, + openMobilePage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: 'mobile', previousViewBeforeMobile: state.activeView === 'mobile' ? state.previousViewBeforeMobile : state.activeView - })), - closeMobilePage: () => - set((state) => ({ + })) + }, + closeMobilePage: () => { + noteViewerNavigation() + return set((state) => ({ activeView: state.previousViewBeforeMobile - })), + })) + }, setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }) } diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index bd6fa428ad3f..118f55de0b6e 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { findWorktreeById } from './worktree-helpers' @@ -271,6 +272,7 @@ function navigateToIndex( if (targetIndex === null) { return } + noteViewerNavigation() const targetEntry = state.worktreeNavHistory[targetIndex] // Why: capture-and-restore (not force false) so re-entrant navigation doesn't clobber the flag. diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts index 521735ef324d..bc62c7b768db 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts @@ -28,6 +28,7 @@ export function buildWorktreePurgeSurfacePatch( } = omitters const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey, changedPaneKeys) return { + agentConversationsByPaneKey: omitByPaneKeyTabPrefix(s.agentConversationsByPaneKey), localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId), diff --git a/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts b/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts index ae9aeafa8710..3599b4eee390 100644 --- a/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts +++ b/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts @@ -38,7 +38,7 @@ import type { AppState } from '../types' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) // Main's frames are applied by hand below; the window's own stream has no main to reach here. -vi.mock('@/runtime/workspace-layout-frame-sync', () => ({ showStartupLayoutFrames: vi.fn() })) +vi.mock('@/runtime/workspace-layout-frame-startup', () => ({ showStartupLayoutFrames: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 76870709c833..05e2894ebacc 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,5 +1,6 @@ import type { TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' +import type { SessionTabOpenPlacement } from '../../../../shared/session-tab-placement' import type { TabSplitTarget } from '../slices/tabs/tabs-slice-contract' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -76,6 +77,7 @@ export type TerminalActions = { /** Published atomically with pendingStartup for automatic resume ownership. */ automaticResumeClaim?: AutomaticAgentResumeClaim activate?: boolean + placement?: SessionTabOpenPlacement['placement'] /** Creates the tab in a new split group instead of targetGroupId. */ split?: TabSplitTarget recordInteraction?: boolean @@ -210,7 +212,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -237,7 +239,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null diff --git a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts index a3ce2cab5578..ba85d9daab54 100644 --- a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts +++ b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts @@ -1,3 +1,4 @@ +import { noteViewerNavigation } from '@/lib/viewer-selection-request' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -12,6 +13,7 @@ export function createActiveWorkspaceTerminalActions( ): Pick { return { openNewTerminalTabInActiveWorkspace: async (groupId) => { + noteViewerNavigation() const state = get() const worktreeId = state.activeWorktreeId if (!worktreeId) { diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index c0b076fb5c47..68160b0627eb 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -64,7 +64,7 @@ export type TerminalState = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 6fd6977f8a6c..f5806c026d01 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -1,3 +1,4 @@ +import { endViewerSelectionForDestination } from '@/lib/viewer-selection-request' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { sweepRetiredTerminalTabState } from '../slices/retired-terminal-tab-state-sweep' import { @@ -72,6 +73,7 @@ export function createTerminalTabCloseActions( const closing = before.find((t) => t.id === tabId) if (closing) { closingWorktreeId = wId + endViewerSelectionForDestination(wId, tabId) // User close cancels pending agent launch; every close settles its startup wait to prevent delivery into a vanished tab. if (closing.agentLaunchPane && !closing.agentLaunchPane.outcome) { if (closeReason === 'user') { diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index ba4a4de7f6fd..d4d6ea523015 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -10,6 +10,7 @@ import { singlePaneLayoutSnapshot } from '../slices/terminal-helpers' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { buildOrphanTerminalCleanupPatch, + removeOrphanTerminalsFromGroups, getOrphanTerminalIds } from '../slices/terminal-orphan-helpers' import { @@ -20,6 +21,7 @@ import { updateGroup } from '../slices/tab-group-state' import { dedupeTabOrder } from '../../../../shared/workspace-layout/tab-order' +import { insertOpenPlacementTab } from '../../lib/session-tab-open-placement-order' import { createBrowserUuid } from '@/lib/browser-uuid' import { emptySplitGroup, insertSplitGroup } from '../slices/tabs/tabs-group-actions' import { ownsGlobalSelection } from '../global-selection-owner' @@ -179,30 +181,7 @@ export function createTerminalTabCreationActions( 'terminal' ) const groupsForWorktree = groupsByWorktree[worktreeId] ?? [] - const cleanedGroups = - orphanTerminalIds.size === 0 - ? groupsForWorktree - : groupsForWorktree.map((entry) => { - // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. - const tabOrder = dedupeTabOrder(entry.tabOrder).filter( - (tabId) => !orphanTerminalIds.has(tabId) - ) - const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) - const replacedActiveTabId = Boolean( - entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) - ) - const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null - const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId - return { - ...entry, - activeTabId, - tabOrder, - recentTabIds: - replacedActiveTabId && activeTabId - ? pushRecentTabId(recentTabIds, activeTabId) - : recentTabIds - } - }) + const cleanedGroups = removeOrphanTerminalsFromGroups(groupsForWorktree, orphanTerminalIds) const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter( (tabId) => !orphanTerminalIds.has(tabId) @@ -224,7 +203,15 @@ export function createTerminalTabCreationActions( // Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode. ...(options?.viewMode ? { viewMode: options.viewMode } : {}) } - const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) + const nextGroupOrder = + options?.placement && !existingTerminalTab + ? insertOpenPlacementTab( + cleanedGroupOrder, + [...existingUnifiedTabs, unifiedTab], + unifiedTab.id, + options.placement + ) + : dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) const nextRecent = shouldActivate ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) : sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder) diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 20a15af56c5c..c848ec033b57 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -61,7 +61,7 @@ export type AgentLaunchIntent = { } /** Placement cannot fail a launch: missing groups fall back to the anchor's group, then the active group. */ -export type AgentLaunchPlacement = { groupId?: string; afterTabId?: string } +export type AgentLaunchPlacement = { groupId?: string; afterTabId?: string; index?: number } /** Whether the caller's own view moves to the new tab. Never names another viewer's screen. */ export type AgentLaunchPresentation = 'focused' | 'background' diff --git a/src/shared/agent-launch-pane-verdict.ts b/src/shared/agent-launch-pane-verdict.ts index b36ece9c65d5..8bb1c47cb4d9 100644 --- a/src/shared/agent-launch-pane-verdict.ts +++ b/src/shared/agent-launch-pane-verdict.ts @@ -22,10 +22,17 @@ export type AgentLaunchPaneVerdict = export const AGENT_LAUNCH_PANE_REFUSED_CODE = 'agent_launch_pane_refused' /** The pane a verdict is about, as the window addresses it. */ -export type AgentLaunchPaneAddress = { worktreeId: string; tabId: string; leafId: string } +export type AgentLaunchPaneAddress = { + worktreeId: string + tabId: string + leafId: string + operationId?: string +} export type AgentLaunchPaneVerdictEvent = AgentLaunchPaneAddress & { verdict: AgentLaunchPaneVerdict + accepted?: true + withdrawalRoot?: string } /** A launch pane as the window saves it: only a pending one, since its outcome is this session's. */ diff --git a/src/shared/agent-launch-tab-publication.ts b/src/shared/agent-launch-tab-publication.ts index d62125604354..d41abfade4c9 100644 --- a/src/shared/agent-launch-tab-publication.ts +++ b/src/shared/agent-launch-tab-publication.ts @@ -2,6 +2,7 @@ import type { AgentLaunchPlacement, AgentLaunchPlacementReceipt } from './agent-launch-intent' import type { TuiAgent } from './tui-agent' +import type { SessionTabOpenPlacement } from './session-tab-placement' /** * Whose screen moves, decided on the host from who called. Mirrors the browser-tab split between @@ -21,13 +22,17 @@ export type AgentLaunchTabViewerRule = export type AgentLaunchTabPublishRequest = { requestId: string + withdraw?: { root: string } worktreeId: string tabId: string leafId: string launchAgent: TuiAgent viewMode: 'terminal' | 'chat' placement?: AgentLaunchPlacement + openPlacement?: SessionTabOpenPlacement viewer: AgentLaunchTabViewerRule + /** Original caller navigation, compared by the addressed viewer at its final selection writer. */ + viewerSelection?: string /** The launch's prompt, so a pane whose agent did not start can offer to copy it. */ prompt?: string /** Which launch this is: a retry of the same one never resets what the tab keeps about it. */ @@ -43,6 +48,8 @@ export type AgentLaunchTabPublishReply = /** True for a tab created by this launch (including just before request); false for retries finding it. */ created: boolean placement: AgentLaunchPlacementReceipt + withdrawalRoot?: string + withdrawn?: boolean } | { requestId: string; error: string } @@ -51,4 +58,18 @@ export type AgentLaunchTabPublished = { tabId: string created: boolean placement: AgentLaunchPlacementReceipt + /** Original provisional root, used only by the creating owner for conditional withdrawal. */ + withdrawalRoot?: string + withdrawn?: boolean +} + +export function launchTabWithdrawalIdentity( + root: unknown, + tabs: readonly { id: string; entityId?: string; groupId?: string }[], + groups: readonly { id: string; tabOrder: string[] }[], + tabId: string +): string { + const tab = tabs.find((row) => row.entityId === tabId || row.id === tabId) + const group = groups.find((row) => row.id === tab?.groupId) + return JSON.stringify([root, tab?.groupId, group?.tabOrder.indexOf(tab?.id ?? '')]) } diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index b542e84ea2ac..e30544d03d41 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -174,7 +174,7 @@ type NoFacts = Record export type AgentSessionConversationOwner = { sessionId: string; workspaceId: string } -type ConversationOwnerFacts = { owner?: AgentSessionConversationOwner } +type ConversationOwnerFacts = { owner?: AgentSessionConversationOwner; source?: true } /** The facts a code carries beside its reason. */ type AgentSessionRefusalFactsByCode = { @@ -328,6 +328,11 @@ export function readAgentSessionRefusalDetails performance.now(), @@ -110,11 +92,14 @@ export type AgentSessionSwitchDeadline = ReturnType boolean check: () => void remaining: () => number signal: AbortSignal - /** Existing physical acquisition only, excluding placement and presentation. */ - trackPhysical?: (work: Promise) => Promise + /** Existing acquisition/creation settlement only; logical failure never proves source exit. */ + trackPhysical?: (work: Promise, recover?: () => Promise) => Promise } export type SessionSwitchProjection = { diff --git a/src/shared/claimed-agent-pty-ensure-options.ts b/src/shared/claimed-agent-pty-ensure-options.ts new file mode 100644 index 000000000000..cd5b5a7958ab --- /dev/null +++ b/src/shared/claimed-agent-pty-ensure-options.ts @@ -0,0 +1,25 @@ +import type { PtyCreationDisposition } from './pty-creation-disposition' +import type { + AgentSessionClaimedSpawnResult, + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from './agent-session-host-authority' +import type { LiveAgentSessionOwner } from './claimed-agent-pty-owner-snapshot' +export type ClaimedAgentPtyEnsureOptions = { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + spawn: (reservation: { generation: string }) => Promise<{ + ptyId: string + owner?: AgentSessionOwnerBinding + disposition?: AgentSessionClaimedSpawnResult['disposition'] + }> + isLive?: (owner: LiveAgentSessionOwner) => boolean | Promise + /** Called when this request waits on another request's start instead of starting its own. */ + creation?: Promise + onJoin?: (creation?: Promise) => void + /** Checked at mutation, including after asynchronous liveness proof. */ + check?: () => void + /** Reconcile an already dispatched creator without granting new attach authority. */ + onDispatchedRevoked?: () => void +} diff --git a/src/shared/claimed-agent-pty-owner.ts b/src/shared/claimed-agent-pty-owner.ts index dd5f233b5e09..e6fe9195afd4 100644 --- a/src/shared/claimed-agent-pty-owner.ts +++ b/src/shared/claimed-agent-pty-owner.ts @@ -1,11 +1,10 @@ +import type { ClaimedAgentPtyEnsureOptions } from './claimed-agent-pty-ensure-options' import type { PtyCreationDisposition } from './pty-creation-disposition' -export type { PtyCreationDisposition } from './pty-creation-disposition' import { randomUUID } from 'node:crypto' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, - AgentSessionOwnerBinding, - AgentSessionSurfaceBinding + AgentSessionOwnerBinding } from './agent-session-host-authority' import { agentSessionClaimKey, @@ -14,12 +13,14 @@ import { buildClaimedAgentPtyOwnerIndex, cloneAgentSessionClaim, cloneAgentSessionOwner, + cloneAgentSessionOwnerBinding, cloneAgentSessionSurface, prepareRegisteredAgentSessionOwner, reconcileClaimedAgentPtyOwnerSnapshot, scopedAgentSessionClaimsEqual, type LiveAgentSessionOwner } from './claimed-agent-pty-owner-snapshot' +export { type PtyCreationDisposition } from './pty-creation-disposition' export { agentSessionOwnerBindingsEqual } from './claimed-agent-pty-owner-snapshot' @@ -36,40 +37,17 @@ type ReservedOwner = { type LiveOwner = LiveAgentSessionOwner -function cloneClaim(claim: AgentSessionExecutionClaim): AgentSessionExecutionClaim { - return cloneAgentSessionClaim(claim) -} - -function cloneSurface(surface: AgentSessionSurfaceBinding): AgentSessionSurfaceBinding { - return cloneAgentSessionSurface(surface) -} - -function cloneOwner(owner: LiveOwner): LiveOwner { - return cloneAgentSessionOwner(owner) -} - export class ClaimedAgentPtyOwnerRegistry { private readonly reserved = new Map() private readonly live = new Map() private readonly conflicts = new Map() private keysByPtyId = new Map>() - async ensure(args: { - claim: AgentSessionExecutionClaim - surface: AgentSessionSurfaceBinding - spawn: (reservation: { generation: string }) => Promise<{ - ptyId: string - owner?: AgentSessionOwnerBinding - disposition?: AgentSessionClaimedSpawnResult['disposition'] - }> - isLive?: (owner: LiveAgentSessionOwner) => boolean | Promise - /** Called when this request waits on another request's start instead of starting its own. */ - onJoin?: (creation?: Promise) => void - creation?: Promise - }): Promise { + async ensure(args: ClaimedAgentPtyEnsureOptions): Promise { // Snapshot reused retry arguments before awaits so caller mutations cannot change registry keys. - const requestedClaim = cloneClaim(args.claim) - const requestedSurface = cloneSurface(args.surface) + args.check?.() + const requestedClaim = cloneAgentSessionClaim(args.claim) + const requestedSurface = cloneAgentSessionSurface(args.surface) const key = agentSessionClaimKey(requestedClaim) if (this.conflicts.has(key)) { throw new Error('agent_session_conflict') @@ -82,13 +60,15 @@ export class ClaimedAgentPtyOwnerRegistry { if (live.claim.worktreeScopeDigest !== requestedClaim.worktreeScopeDigest) { throw new Error('agent_session_conflict') } - if (!args.isLive || (await args.isLive(cloneOwner(live)))) { + if (!args.isLive || (await args.isLive(cloneAgentSessionOwner(live)))) { + args.check?.() const current = this.live.get(key) if (current?.ptyId === live.ptyId && current.generation === live.generation) { - return { disposition: 'adopted', owner: cloneOwner(current) } + return { disposition: 'adopted', owner: cloneAgentSessionOwner(current) } } return await this.ensure(args) } + args.check?.() this.release(live.ptyId, live.generation) } @@ -99,9 +79,11 @@ export class ClaimedAgentPtyOwnerRegistry { } args.onJoin?.(reserved.creation) const result = await reserved.promise - return { disposition: 'adopted', owner: cloneOwner(result.owner as LiveOwner) } + args.check?.() + return { disposition: 'adopted', owner: cloneAgentSessionOwnerBinding(result.owner) } } + args.check?.() this.assertCapacityForNewOwner() const generation = randomUUID() let resolveReservation!: (result: AgentSessionClaimedSpawnResult) => void @@ -126,11 +108,11 @@ export class ClaimedAgentPtyOwnerRegistry { const spawned = await args.spawn({ generation }) const owner: LiveOwner = spawned.owner ? { - claim: cloneClaim(spawned.owner.claim), + claim: cloneAgentSessionClaim(spawned.owner.claim), generation: spawned.owner.generation, phase: 'live', ptyId: spawned.owner.ptyId, - surface: cloneSurface(spawned.owner.surface) + surface: cloneAgentSessionSurface(spawned.owner.surface) } : { claim: requestedClaim, @@ -156,23 +138,36 @@ export class ClaimedAgentPtyOwnerRegistry { if (reservation?.generation !== generation) { throw new Error('agent_session_ownership_unknown') } + const checkDispatched = () => { + try { + args.check?.() + } catch (error) { + if (!args.onDispatchedRevoked) { + throw error + } + args.onDispatchedRevoked() + } + } + checkDispatched() this.live.set(key, owner) const keys = this.keysByPtyId.get(owner.ptyId) ?? new Set() keys.add(key) this.keysByPtyId.set(owner.ptyId, keys) // Index before awaited proof so an exit beating spawn completion can remove the matching generation. - if (args.isLive && !(await args.isLive(cloneOwner(owner)))) { + if (args.isLive && !(await args.isLive(cloneAgentSessionOwner(owner)))) { this.release(owner.ptyId, owner.generation) throw new Error('agent_session_exited_during_start') } + checkDispatched() const current = this.live.get(key) if (current?.ptyId !== owner.ptyId || current.generation !== owner.generation) { throw new Error('agent_session_exited_during_start') } const result: AgentSessionClaimedSpawnResult = { disposition: spawned.disposition ?? 'created', - owner: cloneOwner(owner) + owner: cloneAgentSessionOwner(owner) } + // Dispatch already happened: reconcile its real owner even if this observer expired. resolveReservation(result) published = true return result @@ -282,7 +277,7 @@ export class ClaimedAgentPtyOwnerRegistry { } list(): AgentSessionOwnerBinding[] { - return [...this.live.values()].map(cloneOwner) + return [...this.live.values()].map(cloneAgentSessionOwner) } listForPty(ptyId: string): AgentSessionOwnerBinding[] { @@ -293,12 +288,14 @@ export class ClaimedAgentPtyOwnerRegistry { return [...keys] .map((key) => this.live.get(key)) .filter((owner): owner is LiveOwner => owner !== undefined) - .map(cloneOwner) + .map(cloneAgentSessionOwner) } find(claim: AgentSessionExecutionClaim): AgentSessionOwnerBinding | null { const owner = this.live.get(agentSessionClaimKey(claim)) - return owner && scopedAgentSessionClaimsEqual(owner.claim, claim) ? cloneOwner(owner) : null + return owner && scopedAgentSessionClaimsEqual(owner.claim, claim) + ? cloneAgentSessionOwner(owner) + : null } private rebuildPtyIndex(): void { diff --git a/src/shared/foreground-process-evidence.ts b/src/shared/foreground-process-evidence.ts index 4744575aa137..21d61029cfa7 100644 --- a/src/shared/foreground-process-evidence.ts +++ b/src/shared/foreground-process-evidence.ts @@ -51,6 +51,7 @@ export type RemoteForegroundEvidence = | ({ verdict: 'live' processName: string | null + suspendedAgentProcess?: string fence: PosixFence | WindowsFence } & HostObservation) | ({ verdict: 'unverifiable'; reason: string } & HostObservation) @@ -142,6 +143,8 @@ export function isRemoteForegroundEvidence(value: unknown): value is RemoteForeg if (input.verdict === 'live') { return ( (input.processName === null || typeof input.processName === 'string') && + (input.suspendedAgentProcess === undefined || + isNonEmptyString(input.suspendedAgentProcess)) && (isPosixFence(input.fence) || isWindowsFence(input.fence)) ) } diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index f878cefa3d2c..046d29c66854 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,10 +3,10 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 54, + "daemonProtocolVersion": 55, "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, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, - 51, 52, 53, 54 + 51, 52, 53, 54, 55 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 244e01dc6a1c..55f5434432d7 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,10 +3,10 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 54, + daemonProtocolVersion: 55, 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, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, - 51, 52, 53, 54 + 51, 52, 53, 54, 55 ] } as const diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index c1eed010930a..4079a9244878 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { AgentSessionProviderContextBoundary } from './agent-session-provider-context' // IPC-serializable conversation contract: plain JSON only, no classes/Maps/Dates. Sources prefer transcripts, then hooks, // then degraded scrollback; docs/plans/2026-06-17-001-feat-native-chat-view-plan.md (KTD2). @@ -283,6 +284,7 @@ export const NATIVE_CHAT_INTERRUPTED_STATUS_TEXT = 'Conversation interrupted' /** Replay-safe provider completion/interruption evidence recovered from transcripts, unlike assistant prose. */ export type NativeChatTurnLifecycle = { state: NativeChatTurnLifecycleState + outcome?: AgentJournalTurnOutcome /** Stable provider id when available, otherwise the JSONL record position. */ turnId: string /** Provider timestamp; null only when the transcript omitted one. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 9978cb0aa370..895b94a4bfad 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,3 +1,4 @@ +import { IN_PLACE_SWITCH_RUNTIME_CAPABILITIES } from './structured-agent-cli-conversation-adoption' import { ACCOUNT_RUNTIME_CAPABILITIES } from './account-runtime-capabilities' import * as agentLaunchCapabilities from './agent-launch-runtime-capability' import { @@ -126,6 +127,7 @@ export const SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY = 'agent-session.saved-resume-origin.v1' as const export const RUNTIME_CAPABILITIES = [ + ...IN_PLACE_SWITCH_RUNTIME_CAPABILITIES, SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, ...HOST_RUNTIME_CAPABILITIES, ...DEVICE_KEY_RUNTIME_CAPABILITIES, diff --git a/src/shared/remote-runtime/control/remote-runtime-shared-control-send.ts b/src/shared/remote-runtime/control/remote-runtime-shared-control-send.ts index a67efedf8aa7..f823e25ad464 100644 --- a/src/shared/remote-runtime/control/remote-runtime-shared-control-send.ts +++ b/src/shared/remote-runtime/control/remote-runtime-shared-control-send.ts @@ -31,6 +31,17 @@ export function sendSharedControlSubscription(args: { if (args.subscription.closed || args.subscription.sent) { return } + try { + args.subscription.callbacks.beforeSend?.() + } catch { + finishSharedControlSubscription( + args.subscriptions, + args.subscription, + true, + remoteRuntimeUnavailableError() + ) + return + } if ( args.send({ id: args.subscription.requestId, diff --git a/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts index cd5309ef0a82..173f5b94e981 100644 --- a/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts @@ -24,6 +24,7 @@ export type SharedControlPendingRequest = { } export type SharedControlSubscriptionCallbacks = { + beforeSend?: () => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError: (error: RemoteRuntimeClientError) => void diff --git a/src/shared/remote-runtime/remote-runtime-client.ts b/src/shared/remote-runtime/remote-runtime-client.ts index cbedb0216396..f4118bf5ecf3 100644 --- a/src/shared/remote-runtime/remote-runtime-client.ts +++ b/src/shared/remote-runtime/remote-runtime-client.ts @@ -26,6 +26,7 @@ export type RemoteRuntimeSubscription = { } export type RemoteRuntimeSubscriptionCallbacks = { + beforeSend?: () => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError: (error: RemoteRuntimeClientError) => void diff --git a/src/shared/remote-runtime/requests/remote-runtime-subscription-contract.ts b/src/shared/remote-runtime/requests/remote-runtime-subscription-contract.ts index 275c9238c469..9c468bdf14fd 100644 --- a/src/shared/remote-runtime/requests/remote-runtime-subscription-contract.ts +++ b/src/shared/remote-runtime/requests/remote-runtime-subscription-contract.ts @@ -19,6 +19,7 @@ export type RemoteRuntimeTransportSubscription = { } export type RemoteRuntimeTransportSubscriptionCallbacks = { + beforeSend?: () => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void onError: (error: RemoteRuntimeClientError) => void diff --git a/src/shared/remote-runtime/requests/remote-runtime-subscription-frame-router.ts b/src/shared/remote-runtime/requests/remote-runtime-subscription-frame-router.ts index 074d90fe596e..cc3cb2f86cd3 100644 --- a/src/shared/remote-runtime/requests/remote-runtime-subscription-frame-router.ts +++ b/src/shared/remote-runtime/requests/remote-runtime-subscription-frame-router.ts @@ -17,6 +17,7 @@ type SubscriptionFrameRouterOptions = { // Responses to requests the caller sent over this same socket; unmatched ids stay a hard failure. resolvePendingRequest?: (response: RuntimeRpcResponse) => boolean callbacks: { + beforeSend?: () => void onResponse: (response: RuntimeRpcResponse) => void onBinary?: (bytes: Uint8Array) => void } @@ -107,6 +108,17 @@ export class RemoteRuntimeSubscriptionFrameRouter { ) return } + if (this.options.isSettled()) { + return + } + try { + this.options.callbacks.beforeSend?.() + } catch { + this.options.fail( + new RemoteRuntimeClientError('runtime_unavailable', 'Request expired before dispatch.') + ) + return + } this.state = 'ready' this.options.send(this.options.handshake.channel.sealText(this.options.serializedRequest)) this.options.onAuthenticated() diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 8720dc9e1bb1..7e4d91b4f247 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -604,6 +604,8 @@ import { SleepWorkspaceParams, SubscribeParams, SubscribeTurnCompletionsParams, + SwitchToChatParams, + SwitchToTerminalParams, ThreadGoalParams, UnsubscribeParams } from './structured-agent-session-params' @@ -625,6 +627,7 @@ import { import { AgentTeamsPrepareLaunch, AgentTeamsTmuxCompat, + TerminalClose, TerminalCloseAll, TerminalCreateParams, TerminalFocus, @@ -795,6 +798,8 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeAvailability': AvailabilityParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, + 'agentSession.switchToChat': SwitchToChatParams, + 'agentSession.switchToTerminal': SwitchToTerminalParams, 'agentSession.threadGoal': ThreadGoalParams, 'agentSession.unsubscribe': UnsubscribeParams, 'agentSessionAttachment.read': AttachmentReadParams, @@ -1430,7 +1435,7 @@ export const RPC_PARAMS_BY_METHOD = { 'terminal.adoptOrphans': TerminalAdoptOrphans, 'terminal.agentStatus': TerminalHandle, 'terminal.clearBuffer': TerminalHandle, - 'terminal.close': TerminalHandle, + 'terminal.close': TerminalClose, 'terminal.closeAll': TerminalCloseAll, 'terminal.closeTab': TerminalHandle, 'terminal.create': TerminalCreateParams, diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts index 0cbf8598fbaf..0a95717959b5 100644 --- a/src/shared/rpc-contract/session-tabs-schemas-params.ts +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -166,6 +166,8 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ launchToken: z.string().min(1).max(128).optional(), // Record the resumed conversation before hook reports; drop malformed hints, never the create (#10923). resumeProviderSession: ResumeProviderSessionParam.optional().catch(undefined), + // Sent only to hosts advertising the agent's switch capability: an older host strips it unprotected. + resumeOrigin: z.literal('switch').optional(), agent: z .custom(isTuiAgent, { message: 'Unknown agent preset' @@ -190,6 +192,13 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ // Retry the same in-flight create on double-tap/reconnect instead of spawning another terminal. clientMutationId: z.string().min(1).max(128).optional() }).superRefine((value, context) => { + if (value.resumeOrigin === 'switch' && (!value.resumeProviderSession || !value.launchAgent)) { + context.addIssue({ + code: 'custom', + path: ['resumeOrigin'], + message: 'A switch names its conversation and agent' + }) + } if (value.sourceChatFork) { if (!value.clientMutationId) { context.addIssue({ diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index d9072db1fb09..695114ca7080 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -221,16 +221,7 @@ export const SwitchToChatParams = OptionsParams.extend({ execution: z .object({ invocationId: Identifier('Invalid invocation'), - observe: z.boolean().optional(), - receipt: z - .object({ - generation: Identifier('Invalid generation'), - shown: z.boolean(), - retained: z.boolean().optional(), - converged: z.boolean().optional() - }) - .strict() - .optional(), + viewerSelection: z.string().max(16384).optional(), create: CreateIntentParams.omit({ firstMessage: true }).optional(), source: TerminalClose.required().optional() }) @@ -241,16 +232,7 @@ export const SwitchToTerminalParams = OptionsParams.extend({ execution: z .object({ invocationId: Identifier('Invalid invocation'), - observe: z.boolean().optional(), - receipt: z - .object({ - generation: Identifier('Invalid generation'), - shown: z.boolean(), - retained: z.boolean().optional(), - converged: z.boolean().optional() - }) - .strict() - .optional(), + viewerSelection: z.string().max(16384).optional(), sourceTabId: Identifier('Invalid tab').optional(), groupId: Identifier('Invalid group').optional(), commandOverride: z.string().max(8192).optional() diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index f813d59d0a82..1b584e587780 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -1,3 +1,4 @@ +import type { SessionTabOpenPlacement } from './session-tab-placement' import type { AgentStatusEntry } from './agent-status-types' import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' import type { RuntimeBrowserPlacement } from './runtime-browser-placement' @@ -7,6 +8,7 @@ import type { TuiAgent } from './tui-agent' import type { StructuredAgentId } from './agent-session-provider-handle' export type RuntimeMobileSessionTerminalTab = { + openPlacement?: SessionTabOpenPlacement type: 'terminal' id: string title: string @@ -104,6 +106,7 @@ export type RuntimeMobileSessionBrowserTab = { } export type RuntimeMobileSessionAgentTab = { + openPlacement?: SessionTabOpenPlacement type: 'agent-session' id: string title: string diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 74a3ba9b9f85..5b2a5c01833d 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -56,7 +56,12 @@ export function isBackgroundRuntimeMethod(method: string): boolean { // Slow worktree removal/model listing waits get a separate lane so foreground refresh/send slots remain free; // background slots belong to status. function isLongWaitRuntimeMethod(method: string): boolean { - return method === 'worktree.rm' || method === 'agentSession.modelCatalog' + return ( + method === 'worktree.rm' || + method === 'agentSession.modelCatalog' || + method === 'agentSession.switchToChat' || + method === 'agentSession.switchToTerminal' + ) } // Byte-range reads (media, paged files, downloads) are bulk: a playing video must not hold terminal or workspace slots. diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 4c8e4cb4f1ab..06b72c7be3da 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -18,6 +18,7 @@ import type { import type { CliStatusCaller } from './orchestration-caller-status' import type { RuntimeHostDescriptor } from './runtime-host-descriptor' import type { TuiAgent } from './tui-agent' +import type { SessionSwitchProjection } from './agent-session-switch-execution' export type * from './runtime-mobile-session-tab-contracts' @@ -165,6 +166,7 @@ export type RuntimeMountedLeaf = { } export type RuntimeSyncWindowGraph = { + conditionalSwitchProjection?: 1 tabs: RuntimeSyncedTab[] leaves: RuntimeSyncedLeaf[] /** Layout-owner windows report main-owned panes here; tabs/leaves then cover other workspaces. Window-to-main only. */ @@ -253,6 +255,7 @@ export type RuntimeMobileSessionTabsSnapshot = { activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null + switchProjections?: SessionSwitchProjection[] retiredTerminalSurfaces?: RuntimeMobileSessionRetiredTerminalSurface[] tabs: RuntimeMobileSessionSnapshotTab[] } @@ -277,6 +280,7 @@ export type RuntimeMobileSessionTabsResult = { activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null + switchProjections?: SessionSwitchProjection[] retiredTerminalSurfaces?: RuntimeMobileSessionRetiredTerminalSurface[] tabs: RuntimeMobileSessionClientTab[] /** After restart, client-hosted browser rows may be unknown while terminals are authoritative. Preserve absent @@ -292,6 +296,9 @@ export type RuntimeMobileSessionCreateTerminalResult = { publicationEpoch: string snapshotVersion: number warning?: 'desktop-tab-not-shown' + agentResumeRefused?: true + agentResumeUnavailable?: true + switchOutcomeUnknown?: true /** The saved folder was gone, so the terminal started at the workspace root. */ startupCwdFallback?: 'workspace-root' } diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 630865ca5b1e..f744d02a8cd8 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseCapture } from './terminal-tab-close' import type { TerminalLaunchResult } from './agent-session-resume' import type * as AgentSessionResume from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' @@ -249,6 +250,8 @@ type RuntimeTerminalCreateBaseRequestPayload = { envToDelete?: string[] launchConfig?: AgentSessionResume.SleepingAgentLaunchConfig resumeProviderSession?: AgentSessionResume.AgentProviderSessionMetadata + /** A chat's switch: the pane's spawn admission claims the conversation. */ + resumeOrigin?: 'switch' launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -263,10 +266,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { export type RuntimeTerminalCreateRequestPayload = | (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined }) - | (RuntimeTerminalCreateBaseRequestPayload & { - worktreeId: string - source: 'runtime-session' - }) + | (RuntimeTerminalCreateBaseRequestPayload & { worktreeId: string; source: 'runtime-session' }) export type { RuntimeTerminalCreate } from './runtime-terminal-create-contract' @@ -279,6 +279,8 @@ export type RuntimeTerminalSplit = { } export type RuntimeTerminalResolvePane = TerminalLaunchResult & { + presentation?: TerminalSurfaceCloseCapture + handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/runtime-terminal-create-contract.ts b/src/shared/runtime-terminal-create-contract.ts index e4fc3e55ca52..4b4ac8d5c8e7 100644 --- a/src/shared/runtime-terminal-create-contract.ts +++ b/src/shared/runtime-terminal-create-contract.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from './execution-host' import type { StartupCwdFallback } from './startup-cwd-fallback' export type RuntimeTerminalCreate = TerminalLaunchResult & { + switchOutcomeUnknown?: true coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ diff --git a/src/shared/session-tab-close.ts b/src/shared/session-tab-close.ts index 2817e729a92a..85d91f49a58f 100644 --- a/src/shared/session-tab-close.ts +++ b/src/shared/session-tab-close.ts @@ -1,3 +1,5 @@ +import type { SessionSwitchProjection } from './agent-session-switch-execution' + export const SESSION_TAB_CLOSE_CANCELED_ERROR = 'session_tab_close_canceled' export const SESSION_TAB_CLOSE_FAILED_ERROR = 'session_tab_close_failed' export const SESSION_TAB_NOT_FOUND_ERROR = 'session_tab_not_found' @@ -8,9 +10,11 @@ export type SessionTabCloseRequest = { tabId: string worktreeId: string expiresAt?: number + switchProjection?: SessionSwitchProjection } export type SessionTabCloseResponse = { requestId: string error?: string + switchApplied?: boolean } diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts index 0727f809675c..3e75fd0e089e 100644 --- a/src/shared/session-tab-placement.ts +++ b/src/shared/session-tab-placement.ts @@ -1,5 +1,22 @@ +import type { TerminalSurfaceCloseIdentity } from './terminal-tab-close' import { insertTabIdIntoOrder, partitionPinnedTabOrder } from './workspace-layout/tab-order' +/** The existing host command's open intent, carried with membership to the actual layout owner. */ +export type SessionTabOpenPlacement = { + invocationId: string + generation: string + placement: { groupId?: string; afterTabId?: string; index?: number } + activate: boolean + viewerSelection?: string + /** Present for terminal-to-chat; source reuse cannot turn its captured index into authority. */ + source?: TerminalSurfaceCloseIdentity +} + +/** Recognizes viewer-owned requests; only the initiating viewer can admit or consume one. */ +export function isSessionTabSelectionRequest(token: string | undefined): boolean { + return token?.startsWith('selection-v1:') === true && token.length > 'selection-v1:'.length +} + /** Call-local neighbors survive an anchor retiring while creation is awaited. */ export type CapturedSessionTabPlacement = { preceding: string[]; following: string[] } diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts index 5a882f5b2327..fa68a5e3e5cd 100644 --- a/src/shared/structured-agent-cli-conversation-adoption.ts +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -36,3 +36,34 @@ export function cliConversationRowsShareAgent( const rightAgents: readonly string[] = CLI_CONVERSATION_ADOPTING_AGENTS[right] return CLI_CONVERSATION_ADOPTING_AGENTS[left].some((agent) => rightAgents.includes(agent)) } + +/** The one enablement source for moving a conversation between chat and terminal in place: the + * agents whose chat catches up on turns made elsewhere and whose terminal start Orca can watch. */ +export const IN_PLACE_SWITCH_AGENTS: readonly CliConversationRowAgent[] = [ + 'claude', + 'codex', + 'grok', + 'opencode', + 'opencode2', + 'pi' +] + +export function inPlaceSwitchEnabled(agent: unknown): boolean { + return adoptsCliConversations(agent) && IN_PLACE_SWITCH_AGENTS.includes(agent) +} + +/** What a host advertises per enabled agent; a peer without it never receives a switch. */ +export function inPlaceSwitchCapability(agent: string): string { + return `agent-session.structured.switch.creation-settlement-v1:${agent}` +} + +export const IN_PLACE_SWITCH_RUNTIME_CAPABILITIES = IN_PLACE_SWITCH_AGENTS.map( + inPlaceSwitchExecutionCapability +) + +export function inPlaceSwitchExecutionCapability(agent: string): string { + return `agent-session.structured.switch.creation-settlement-v1:${agent}` +} + +/** `saved`: built from a pane's saved facts; `switch`: the terminal half of a chat's switch. */ +export type TerminalResumeOrigin = 'saved' | 'switch' diff --git a/src/shared/terminal-layout-leaf-retirement.ts b/src/shared/terminal-layout-leaf-retirement.ts index edef75b76f9a..6e96c0081744 100644 --- a/src/shared/terminal-layout-leaf-retirement.ts +++ b/src/shared/terminal-layout-leaf-retirement.ts @@ -55,6 +55,8 @@ export function retireLeavesFromTerminalLayout( layout.expandedLeafId && retainedLeafIdSet.has(layout.expandedLeafId) ? layout.expandedLeafId : null, + chatLeafId: + layout.chatLeafId && !retiredLeafIds.has(layout.chatLeafId) ? layout.chatLeafId : undefined, ptyIdsByLeafId: omitLeafRecords(layout.ptyIdsByLeafId, retiredLeafIds), buffersByLeafId: omitLeafRecords(layout.buffersByLeafId, retiredLeafIds), scrollbackRefsByLeafId: omitLeafRecords(layout.scrollbackRefsByLeafId, retiredLeafIds), diff --git a/src/shared/terminal-process-inspection.ts b/src/shared/terminal-process-inspection.ts index 4cdfe32e092c..7f16368dea34 100644 --- a/src/shared/terminal-process-inspection.ts +++ b/src/shared/terminal-process-inspection.ts @@ -27,6 +27,9 @@ export type ClientOnlyUnverifiableInspection = { /** Compatibility-shaped host/local inspection returned by the inspect RPC. */ export type HostProcessInspection = { + switchWitness?: string + suspendedAgentProcess?: string + foregroundProcess: string | null hasChildProcesses: boolean /** Optional on old hosts; the renderer treats an omitted field as old-host unverifiable. */ diff --git a/src/shared/terminal-tab-close.ts b/src/shared/terminal-tab-close.ts index 61f33b35ffe6..df15ce7c146c 100644 --- a/src/shared/terminal-tab-close.ts +++ b/src/shared/terminal-tab-close.ts @@ -1,6 +1,6 @@ import type { ExecutionHostId } from './execution-host' import type { WorkspaceSessionState } from './workspace-session-state-types' -import type { TerminalPaneLayoutNode } from './terminal-tab-types' +import type { TerminalPaneLayoutNode, TerminalTab } from './terminal-tab-types' export type TerminalTabCloseRequest = { requestId: string @@ -72,6 +72,20 @@ export function captureTerminalSurfaceIdentity( leafId: string ): TerminalSurfaceCloseIdentity | null { const row = state?.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + return captureTerminalSurfaceIdentityFromRow(state, row, worktreeId, tabId, leafId) +} + +export function captureTerminalSurfaceIdentityFromRow( + state: + | (Pick & + Partial>) + | null + | undefined, + row: TerminalTab | undefined, + worktreeId: string, + tabId: string, + leafId: string +): TerminalSurfaceCloseIdentity | null { if (!row || !state) { return null } diff --git a/src/shared/workspace-layout/workspace-layout-published.ts b/src/shared/workspace-layout/workspace-layout-published.ts index ad435a572519..6b86bc46c619 100644 --- a/src/shared/workspace-layout/workspace-layout-published.ts +++ b/src/shared/workspace-layout/workspace-layout-published.ts @@ -1,5 +1,6 @@ // Explicit layout.subscribe wire projection includes terminal startup inputs and permits live pane titles/status. Persistence-only records and view selection stay private. +import type { SessionTabOpenPlacement } from '../session-tab-placement' import type { AgentType } from '../agent-status-types' import type { AiVaultSessionTitle } from '../ai-vault-session-title' import type { ExecutionHostId } from '../execution-host' @@ -55,6 +56,8 @@ export type PublishedTerminal = { > export type PublishedTab = { + /** Ephemeral ordinary open intent, overlaid by the runtime; never a stored LayoutTab field. */ + openPlacement?: SessionTabOpenPlacement id: string kind: TabContentType entityId: string diff --git a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts new file mode 100644 index 000000000000..32cb82043168 --- /dev/null +++ b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { captureSourceTerminal } from '@/components/tab-bar/cli-chat-resume-terminal-capture' +import { buildMirroredTerminalTabs } from '@/runtime/web-session-tabs-sync/terminal-build' +import type { captureTerminalSurfaceIdentity } from '../../src/shared/terminal-tab-close' +import { + createHarness, + WORKTREE_ID, + TAB_ID, + LEAF_ID +} from '../../src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +const { rpc } = vi.hoisted(() => ({ rpc: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + rpc.mockReset() +}) +function source(ptyId: string | undefined, executionHostId: 'local' | 'runtime:paired' = 'local') { + return { + workspaceId: WORKTREE_ID, + tabId: TAB_ID, + paneKey: `${TAB_ID}:${LEAF_ID}`, + agent: 'codex' as const, + providerSessionId: 'conversation', + executionHostId, + ptyId + } +} +it('captures a real mirrored source on host coordinates and keeps client continuity independent of host clocks', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + harness.editSession((session) => ({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [WORKTREE_ID]: session.tabsByWorktree[WORKTREE_ID].map((row) => ({ ...row, generation: 7 })) + } + })) + const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + const [mirror] = buildMirroredTerminalTabs(snapshot, 'paired', new Map(), {}, {}, 0, 9000) + if (!mirror || !terminal.presentation) { + throw new Error('Expected a real published terminal') + } + const mirrored = { + ...source(mirror.layout.ptyIdsByLeafId?.[LEAF_ID], 'runtime:paired'), + tabId: mirror.tab.id, + paneKey: `${mirror.tab.id}:${LEAF_ID}` + } + const seedMirror = () => { + const presentationState: NonNullable[0]> = { + tabsByWorktree: { [WORKTREE_ID]: [mirror.tab] }, + terminalLayoutsByTabId: { [mirror.tab.id]: mirror.layout }, + terminalPtyIncarnationsByPaneKey: {} + } + useAppStore.setState({ + ...presentationState, + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:paired' + }) + } + seedMirror() + rpc.mockImplementation( + async (_target, method, params: { paneKey: string; worktreeId: string }) => { + expect(method).toBe('terminal.resolvePane') + expect(params).toEqual({ paneKey: `${TAB_ID}:${LEAF_ID}`, worktreeId: WORKTREE_ID }) + return { terminal: harness.runtime.resolveTerminalPane(params.paneKey, params.worktreeId) } + } + ) + expect(mirrored.tabId).not.toBe(terminal.presentation.identity.tabId) + expect(mirror.tab.createdAt).not.toBe(terminal.presentation.identity.createdAt) + expect(mirror.tab.generation ?? 0).not.toBe(terminal.presentation.identity.generation) + expect(await captureSourceTerminal(mirrored)).toEqual({ + target: { kind: 'environment', environmentId: 'paired' }, + handle: terminal.handle, + incarnationId: terminal.incarnationId, + presentation: terminal.presentation + }) +}) diff --git a/tests/e2e/conversation-switch-desktop-retirement.unit.test.ts b/tests/e2e/conversation-switch-desktop-retirement.unit.test.ts new file mode 100644 index 000000000000..a832ec5a95a2 --- /dev/null +++ b/tests/e2e/conversation-switch-desktop-retirement.unit.test.ts @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { publishAgentLaunchTab } from '@/lib/agent-launch-tab-publication' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { registerSessionTabIpcBridge } from '@/hooks/ipc-events/session-tab-ipc-bridge' +import { buildMobileSessionTabSnapshots } from '@/runtime/sync-runtime-graph/mobile-session-snapshots' +import { mobilePublicationEpoch } from '@/runtime/sync-runtime-graph/graph-state' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' +import { resetLocalStructuredSessionVersionForTests } from '@/runtime/local-structured-session-tabs-sync/inventory-generation-fence' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { + createHarness, + WORKTREE_ID +} from '../../src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { createMobileCreateTestNotifier } from '../../src/main/runtime/orca-runtime-test-scenario-builders.spec' +import { electronMocks } from '../../src/main/runtime/orca-runtime-test-mocks.spec' +import { setRuntimeDesktopSurface } from '../../src/main/runtime/runtime-desktop-surface' +import { electronRuntimeDesktopSurface } from '../../src/main/host/electron-runtime-desktop-surface' +import { createRestTestRig } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + createTestParams, + CREATE_TEST_CALLER +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestMessage +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { agentSessionSwitchDeadline } from '../../src/shared/agent-session-switch-execution' +import { structuredAgentSessionTabId } from '../../src/shared/structured-agent-session-projection' +import type { + SessionTabCloseRequest, + SessionTabCloseResponse +} from '../../src/shared/session-tab-close' + +const { receipts } = vi.hoisted(() => ({ receipts: vi.fn(async () => {}) })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: receipts +})) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) + +afterEach(() => { + setRuntimeDesktopSurface(null) + electronMocks.BrowserWindow.fromId.mockReset() + resetLocalStructuredSessionVersionForTests() + vi.clearAllMocks() +}) + +it.each(['saved', 'empty'] as const)( + 'diagnoses focused %s desktop retirement through real IPC, visibility and inventory owners', + async (content) => { + useAppStore.setState(useAppStore.getInitialState(), true) + createTabsSliceMockApi() + const state = () => useAppStore.getState() + state().setActiveWorktree(WORKTREE_ID) + const neighbor = state().createTab(WORKTREE_ID) + const source = state().createUnifiedTab(WORKTREE_ID, 'agent-session', { + id: structuredAgentSessionTabId(HOST_TEST_SESSION), + entityId: HOST_TEST_SESSION, + agentSessionAgent: 'codex' + }) + useAppStore.setState({ + unifiedTabsByWorktree: { + ...state().unifiedTabsByWorktree, + [WORKTREE_ID]: state().unifiedTabsByWorktree[WORKTREE_ID]!.map((tab) => + tab.id === source.id ? { ...tab, sortOrder: 0 } : tab + ) + } + }) + state().activateTab(source.id) + const order = () => + state().groupsByWorktree[WORKTREE_ID]!.find((group) => group.id === source.groupId)!.tabOrder + expect(order()).toEqual([neighbor.id, source.id]) + const rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + const deadline = agentSessionSwitchDeadline() + const unsubs: (() => void)[] = [] + try { + const params = createTestParams(undefined, { + surfaceTabId: source.id, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + } + }) + expect(await rig.host.create(CREATE_TEST_CALLER, params)).toMatchObject({ ok: true }) + if (content === 'saved') { + expect(await rig.host.switchToChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) + const events = rig.adapter.acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('Expected the host acquisition event sink') + } + events.appendItem( + { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'saved', ordinal: 1 }, + hostTestMessage('Saved conversation'), + { turnScope: { kind: 'thread' } } + ) + await rig.host.flushStreamedEvents(HOST_TEST_SESSION) + } + await rig.host.switchToTerminal(HOST_TEST_SESSION, deadline) + setStructuredAgentSessionHost(rig.host) + await rig.store.setSessionTabVisibility(HOST_TEST_SESSION, true, source.id) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, setBackgroundThrottling: () => {} } + }) + setRuntimeDesktopSurface({ + showNotification: () => false, + findWindowById: electronRuntimeDesktopSurface.findWindowById, + onIpc: () => {}, + removeIpcListener: () => {} + }) + const harness = createHarness() + harness.removeVictimFromInventory() + harness.editSession(() => buildWorkspaceSessionPayload(state())) + const publishGraph = () => + harness.runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + rendererGeneration: mobilePublicationEpoch, + conditionalSwitchProjection: 1, + mobileSessionTabs: buildMobileSessionTabSnapshots(state(), false) + }) + publishGraph() + harness.runtime.projectStructuredAgentSessionTab({ + workspaceId: WORKTREE_ID, + sessionId: HOST_TEST_SESSION, + agent: 'codex', + activate: false + }) + const before = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + let onClose!: (request: SessionTabCloseRequest) => void + const pending = new Map void>() + const responses: SessionTabCloseResponse[] = [] + Object.assign(window.api.ui, { + onCloseSessionTab: () => () => {}, + onMoveSessionTab: () => () => {}, + onSessionTabCloseRequest: (listener: typeof onClose) => { + onClose = listener + return () => {} + }, + respondSessionTabClose: (response: SessionTabCloseResponse) => { + responses.push(response) + pending.get(response.requestId)!(response.switchApplied === true) + pending.delete(response.requestId) + } + }) + registerSessionTabIpcBridge(unsubs) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const phases: string[] = [] + harness.runtime.setNotifier({ + ...createMobileCreateTestNotifier(vi.fn()), + closeSessionTab: async (tabId, worktreeId, switchProjection) => { + const requestId = `close-${phases.length}` + phases.push(switchProjection?.phase ?? 'ordinary') + const answer = new Promise((resolve) => { + pending.set(requestId, resolve) + onClose({ requestId, tabId, worktreeId, switchProjection }) + }) + if (switchProjection?.phase === 'retire') { + entered.resolve() + await release.promise + } + return answer + } + }) + const restore = vi.fn(async () => { + await rig.store.setSessionTabVisibility(HOST_TEST_SESSION, true, source.id) + }) + const retire = harness.runtime.captureSessionSwitchRetirement( + WORKTREE_ID, + HOST_TEST_SESSION, + source.id, + rig.host.captureSwitchWork(HOST_TEST_SESSION, deadline), + restore, + { + invocationId: 'desktop-diagnostic', + generation: 'desktop-generation', + sessionId: HOST_TEST_SESSION, + tabId: source.id, + phase: 'prepare' + } + ) + const destination = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + publishAgentLaunchTab({ + requestId: 'destination', + operationId: 'desktop-diagnostic', + worktreeId: WORKTREE_ID, + tabId: destination, + leafId: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + launchAgent: 'codex', + viewMode: 'terminal', + viewer: 'focus-window', + placement: { groupId: source.groupId!, afterTabId: source.id } + }) + expect(state().activeTabId).toBe(destination) + expect(order()).toEqual([neighbor.id, source.id, destination]) + publishGraph() + const closing = retire() + const retired = await Promise.race([ + entered.promise.then(() => 'ipc'), + closing.then(() => 'settled') + ]) + try { + if (retired === 'ipc') { + expect(rig.store.listVisibleSessionIds()).not.toContain(HOST_TEST_SESSION) + applyStructuredSessionTabSnapshots([before], undefined, { reacceptCurrentVersion: true }) + } + } finally { + release.resolve() + await closing + } + expect(await closing).toBe(true) + expect(phases).toEqual(['prepare', 'retire']) + expect(responses.map((response) => response.switchApplied)).toEqual([true, true]) + expect(restore).not.toHaveBeenCalled() + const inventory = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + applyStructuredSessionTabSnapshots([inventory], undefined, { reacceptCurrentVersion: true }) + expect(rig.store.getVisibleSessionTabIndex().sessionIds).not.toContain(HOST_TEST_SESSION) + expect( + inventory.tabs.some( + (tab) => tab.type === 'agent-session' && tab.sessionId === HOST_TEST_SESSION + ) + ).toBe(false) + expect(state().unifiedTabsByWorktree[WORKTREE_ID]!.some((tab) => tab.id === source.id)).toBe( + false + ) + expect(order()).toEqual([neighbor.id, destination]) + expect(receipts.mock.calls.flat()).not.toContainEqual( + expect.objectContaining({ + execution: expect.objectContaining({ + receipt: expect.objectContaining({ retained: true }) + }) + }) + ) + } finally { + unsubs.forEach((unsubscribe) => unsubscribe()) + setStructuredAgentSessionHost(null) + deadline.dispose() + await rig.dispose() + } + } +) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index e82201a20315..a14f93ee9311 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -29,7 +29,7 @@ import { resolveBaselineReleaseRef } from './release-checkout' import { callBuild, expectDeclaredSurfaceExecutes, - expectItemDetailNegotiation, + expectItemDetailAndSwitchNegotiation, runtimeStub } from './structured-agent-session-surface-execution' import { @@ -117,7 +117,7 @@ describe('cross-version structured agent sessions', () => { ) describe('a client that never asked for structured sessions', () => { - let hostCalls: Record> + let hostCalls: ReturnType beforeEach(() => { resetOperationIds() @@ -284,7 +284,7 @@ describe('cross-version structured agent sessions', () => { build.capabilities.includes(AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY) ).toBe(build.methodNames.includes(CONVERSATION_OUTLINE_METHOD)) } - expectItemDetailNegotiation([current, baseline]) + expectItemDetailAndSwitchNegotiation([current, baseline]) // Additive surface: bumping the protocol number would strand every paired // device on this release rather than degrade one feature. expect(current.protocolVersion).toBe(baseline.protocolVersion) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index eef82ab2ff36..3be90d5e7da2 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -1,4 +1,15 @@ import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../src/shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../../src/shared/agent-session-record' +import { agentSessionRefusalError } from '../../../src/shared/agent-session-wire-refusals' +import type { AiVaultChatForkSource } from '../../../src/shared/ai-vault-resume-preparation' import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' @@ -11,13 +22,20 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +type StructuredHostCalls = Record> & { + getRecord: Mock<(sessionId: string) => AgentSessionRecord | null> +} + // Use per-method spies so every skew can prove which call reached the host. -export function structuredHostStub( - sessionId: string, - workspaceId: string -): Record> { +export function structuredHostStub(sessionId: string, workspaceId: string): StructuredHostCalls { return { - // Keep restart-resume spies flat for manifest/delta assertions; installableHost rebuilds the nested member. + getRecord: vi.fn<(sessionId: string) => AgentSessionRecord | null>(() => null), + switchToTerminal: vi.fn(async () => undefined), + switchToChat: vi.fn(async () => ({ ok: true })), + // The restart-resume surface hangs off a host MEMBER rather than the root, but its spies stay + // flat here: callers iterate this map asserting every entry is a spy that did not run, and + // `installableHost` below is what reassembles the member. Keeping them flat also lets the + // manifest name them to prove a call reached the host. restartResumableList: vi.fn(async () => []), restartResumableFailures: vi.fn(async () => []), restartResumableDismiss: vi.fn(async () => 0), @@ -119,15 +137,30 @@ export function structuredHostStub( } // Install the nested host shape, but assert against the flat spy map. -export function installableHost( - hostCalls: Record> -): StructuredAgentSessionHost { +export function installableHost(hostCalls: StructuredHostCalls): StructuredAgentSessionHost { const host = { ...hostCalls, // The catalog read checks the session's record for a floating chat's own folder; none here. deps: { modelCatalog: { read: hostCalls.modelCatalog, demand: vi.fn(async () => {}) }, - store: { getRecord: () => null, getConversation: () => null, getOperationRow: () => null } + store: { + getRecord: hostCalls.getRecord, + selectWritableRecord: (sessionId: string) => { + const record: AgentSessionRecord | null = hostCalls.getRecord(sessionId) + return record ? { kind: 'writable' as const, record } : { kind: 'missing' as const } + }, + requireWritableRecord: (sessionId: string) => { + const record: AgentSessionRecord | null = hostCalls.getRecord(sessionId) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + return record + }, + getConversation: () => null, + getOperationRow: () => null + } }, restartResume: { list: hostCalls.restartResumableList, @@ -142,6 +175,54 @@ export function installableHost( return host as unknown as StructuredAgentSessionHost } +/** A saved conversation for the real switch handler's account and transcript checks. */ +export async function structuredSwitchSource( + sessionId: string, + workspaceId: string, + thread: string +) { + const root = await mkdtemp(join(tmpdir(), 'orca-cross-version-switch-')) + const codexHome = join(root, 'codex-home') + const sessions = join(codexHome, 'sessions') + const filePath = join(sessions, `rollout-${thread}.jsonl`) + await mkdir(sessions, { recursive: true }) + await writeFile( + filePath, + `${JSON.stringify({ type: 'session_meta', payload: { id: thread, cwd: root } })}\n` + ) + const record: AgentSessionRecord = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), + provider: 'codex', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' + }, + accountHome: { variable: 'CODEX_HOME', path: codexHome }, + launchDirectory: root, + providerHandleChain: [ + { + linkId: 'link-1', + origin: 'created', + mintedAtFence: 7, + observedAt: 1_000, + handle: codexProviderHandle(thread) + } + ] + } + const source: AiVaultChatForkSource = { + agent: 'codex', + sourceAgent: 'codex', + sessionId: thread, + cwd: root, + codexHome, + filePath, + executionHostPlatform: process.platform + } + return { record, source, dispose: () => rm(root, { recursive: true, force: true }) } +} + const TURN = { turnId: 'turn-1', state: 'completed' as const, startedAt: 1, completedAt: 6 } const TURN_ROW = { itemId: 'legacy:codex:s:turn-1', revision: 1, sequence: 1, observedAt: 1 } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index 6048f5a59d01..d90b19c2ecb5 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -14,6 +14,8 @@ export const CONVERSATION_OUTLINE_METHOD = 'agentSession.conversationOutline' export const ITEM_DETAIL_METHOD = 'agentSession.item' export const STATUS_FEED_METHOD = 'agentSession.subscribeStatus' export const TURN_COMPLETION_FEED_METHOD = 'agentSession.subscribeTurnCompletions' +export const SWITCH_TO_TERMINAL_METHOD = 'agentSession.switchToTerminal' +export const SWITCH_TO_CHAT_METHOD = 'agentSession.switchToChat' // Declare each method’s expected host call and result; registration or a non-method_not_found error cannot prove execution. // Omit result only for streams that legitimately open without a reply. @@ -241,5 +243,15 @@ export const STRUCTURED_CALLS: { method: 'agentSession.subscribeAvailability', hostMethod: null, result: { type: 'availability', agents: [] } + }, + { + method: SWITCH_TO_TERMINAL_METHOD, + hostMethod: 'switchToTerminal', + result: { source: { agent: 'codex', sourceAgent: 'codex', sessionId: THREAD } } + }, + { + method: SWITCH_TO_CHAT_METHOD, + hostMethod: 'switchToChat', + result: { ok: true, sessionId: SESSION } } ] diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 43792f0a74d9..1837621bc9de 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -3,10 +3,20 @@ import { AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY } from '../../../src/share import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import { resetStructuredAgentSessionClosesBeforeCreateForTests } from '../../../src/main/runtime/rpc/methods/structured-agent-session-close-before-create' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { inPlaceSwitchCapability } from '../../../src/shared/structured-agent-cli-conversation-adoption' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../src/shared/agent-session-journal-types' +import type { AiVaultSessionResumePreparation } from '../../../src/shared/ai-vault-resume-preparation' +import { structuredSwitchSource } from './structured-agent-session-host-fixture' import { attachParams, ATTENTION_READ, paramsFor, + SESSION, + WORKSPACE, + THREAD, + SWITCH_TO_TERMINAL_METHOD, + SWITCH_TO_CHAT_METHOD, STRUCTURED_CALLS } from './structured-agent-session-surface-manifest' import type { @@ -71,71 +81,141 @@ export async function expectDeclaredSurfaceExecutes( clientCapabilities: readonly string[] ): Promise { const retirement = vi.fn() - const runtime = runtimeStub({ retireStructuredAttention: retirement }) - for (const { method, hostMethod, result, clientCapability } of STRUCTURED_CALLS) { - const atRestCreate = - method === 'agentSession.create' && - build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && - clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) - const expectedHostMethod = atRestCreate ? 'create' : hostMethod - const otherCreateMethod = - method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null - // Shared host methods need per-call deltas; an earlier call must not satisfy this assertion. - const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 - const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 - const replies = await callBuild( - build, - method, - paramsFor(method), - { clientKind: 'runtime', clientCapabilities }, - runtime - ) - if (clientCapability && !clientCapabilities.includes(clientCapability)) { - expect(replies).toMatchObject([ - { - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + const saved = await structuredSwitchSource(SESSION, WORKSPACE, THREAD) + const preparation = vi.fn(async () => ({ + useRealCodexHome: false + })) + const runtime = runtimeStub({ + retireStructuredAttention: retirement, + prepareAiVaultSessionResume: preparation + }) + try { + for (const { method, hostMethod, result, clientCapability } of STRUCTURED_CALLS) { + const switching = method === SWITCH_TO_TERMINAL_METHOD || method === SWITCH_TO_CHAT_METHOD + hostCalls.getRecord.mockImplementation((sessionId: string) => + switching && sessionId === SESSION ? saved.record : null + ) + if (method === SWITCH_TO_TERMINAL_METHOD) { + const snapshot: AgentJournalSnapshot = { + sessionId: SESSION, + cursor: { epoch: 'epoch-a', sequence: 1 }, + submissions: [], + items: [ + { + itemId: agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: 'turn-1', + ordinal: 0 + }), + revision: 1, + sequence: 1, + observedAt: 1_000, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Saved prompt' }] + } + } + ] + } + hostCalls.journalSnapshot.mockReturnValueOnce(snapshot) + } + const atRestCreate = + method === 'agentSession.create' && + build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && + clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + const expectedHostMethod = atRestCreate ? 'create' : hostMethod + const otherCreateMethod = + method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null + // Two methods share one host method, so "has been called" would already be + // true from the earlier one: only this call's own delta pins the pairing. + const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 + const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 + const replies = await callBuild( + build, + method, + paramsFor(method), + { clientKind: 'runtime', clientCapabilities }, + runtime + ) + if (clientCapability && !clientCapabilities.includes(clientCapability)) { + expect(replies).toMatchObject([ + { + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + } + ]) + if (expectedHostMethod) { + expect(hostCalls[expectedHostMethod].mock.calls.length).toBe(before) } - ]) + continue + } if (expectedHostMethod) { - expect(hostCalls[expectedHostMethod].mock.calls.length).toBe(before) + expect( + hostCalls[expectedHostMethod].mock.calls.length - before, + `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` + ).toBe(1) + } + if (otherCreateMethod) { + expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) + } + if (method === 'agentSession.acknowledgeAttention') { + expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) + } + if (method === SWITCH_TO_CHAT_METHOD) { + expect(hostCalls.switchToChat).toHaveBeenLastCalledWith(SESSION) + } + if (method === SWITCH_TO_TERMINAL_METHOD) { + expect(hostCalls.switchToTerminal).toHaveBeenLastCalledWith(SESSION) + expect(preparation).toHaveBeenCalledExactlyOnceWith({ + agent: 'codex', + sessionId: THREAD, + filePath: saved.source.filePath, + codexHome: saved.source.codexHome, + executionHostId: 'local' + }) + expect(preparation.mock.invocationCallOrder[0]).toBeLessThan( + hostCalls.switchToTerminal.mock.invocationCallOrder[0] + ) + expect(replies[0]).toMatchObject({ ok: true, result: { source: saved.source } }) + } + for (const reply of replies) { + expect( + reply, + `${build.label}: ${method} was refused: ${JSON.stringify(reply)}` + ).toMatchObject({ ok: true }) + } + if (result) { + // The declared answer, not merely a non-refusal: a handler that is + // registered and returns an execution error, or hands back someone else's + // envelope, fails here rather than passing as "reached the host". + expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${build.label}: ${method} answered off-contract`).toMatchObject({ + ok: true, + result + }) } - continue - } - if (expectedHostMethod) { - expect( - hostCalls[expectedHostMethod].mock.calls.length - before, - `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` - ).toBe(1) - } - if (otherCreateMethod) { - expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) - } - if (method === 'agentSession.acknowledgeAttention') { - expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) - } - for (const reply of replies) { - expect( - reply, - `${build.label}: ${method} was refused: ${JSON.stringify(reply)}` - ).toMatchObject({ ok: true }) - } - if (result) { - // Assert the exact envelope so registered-but-failing handlers cannot pass. - expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) - expect(replies[0], `${build.label}: ${method} answered off-contract`).toMatchObject({ - ok: true, - result - }) } + } finally { + hostCalls.getRecord.mockReturnValue(null) + await saved.dispose() } } -export function expectItemDetailNegotiation(builds: readonly AgentSessionWireBuild[]): void { +export function expectItemDetailAndSwitchNegotiation( + builds: readonly AgentSessionWireBuild[] +): void { expect(builds[0]?.capabilities).toContain(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY) + expect(builds[0]?.capabilities).toContain(inPlaceSwitchCapability('codex')) for (const build of builds) { expect(build.capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY)).toBe( build.methodNames.includes('agentSession.item') ) + for (const method of [SWITCH_TO_TERMINAL_METHOD, SWITCH_TO_CHAT_METHOD]) { + expect(build.capabilities.includes(inPlaceSwitchCapability('codex'))).toBe( + build.methodNames.includes(method) + ) + } } } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 181866459351..5e0723308219 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -29,6 +29,8 @@ export { ITEM_DETAIL_METHOD, STATUS_FEED_METHOD, TURN_COMPLETION_FEED_METHOD, + SWITCH_TO_TERMINAL_METHOD, + SWITCH_TO_CHAT_METHOD, STRUCTURED_CALLS } from './structured-agent-session-surface-calls' diff --git a/tests/e2e/helpers/native-chat-switch-readable-source.ts b/tests/e2e/helpers/native-chat-switch-readable-source.ts new file mode 100644 index 000000000000..f2f80b14cd8a --- /dev/null +++ b/tests/e2e/helpers/native-chat-switch-readable-source.ts @@ -0,0 +1,78 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import * as cliTranscript from '../../../src/main/ai-vault/structured-chat-cli-fork' +import type { RestTestRig } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + createTestParams, + CREATE_TEST_CALLER +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { HOST_TEST_SESSION } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { structuredAgentRuntimeMetadata } from '../../../src/main/runtime/structured-agent-runtime-metadata' +import { agentSessionProviderHandleChainHead } from '../../../src/shared/agent-session-provider-handle' + +export async function prepareReadableSwitchSource(rig: RestTestRig, workspaceId: string) { + const params = createTestParams() + await rig.host.create( + CREATE_TEST_CALLER, + createTestParams(undefined, { + location: { ...params.location, workspaceId } + }) + ) + await rig.host.switchToChat(HOST_TEST_SESSION) + await rig.host.switchToTerminal(HOST_TEST_SESSION) + rig.adapter.acquire.mockClear() + const record = rig.store.getRecord(HOST_TEST_SESSION)! + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)! + const nativeId = head.handle.nativeId + const codexHome = join(rig.root, 'codex-home') + const sessionsDir = join(codexHome, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const filePath = join(sessionsDir, 'readable.jsonl') + await writeFile( + filePath, + `${[ + { type: 'session_meta', payload: { id: nativeId } }, + { type: 'event_msg', payload: { type: 'task_started', turn_id: 'terminal-turn' } }, + { + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'Readable before Codex is ready' + } + }, + { type: 'event_msg', payload: { type: 'task_complete', turn_id: 'terminal-turn' } } + ] + .map((row) => JSON.stringify({ timestamp: '2026-10-26T12:00:00Z', ...row })) + .join('\n')}\n` + ) + // External lookup is the I/O boundary; import, journal, command and root stay real. + vi.spyOn(cliTranscript, 'resolveStructuredChatCliSource').mockResolvedValue({ + host: rig.host, + record, + head, + agent: 'codex', + cwd: rig.root, + providerSessionId: nativeId, + metadata: structuredAgentRuntimeMetadata('codex')!, + requireCurrent: () => { + const current = rig.store.requireWritableRecord(record.sessionId) + expect(current.location).toEqual(record.location) + expect(agentSessionProviderHandleChainHead(current.providerHandleChain)?.linkId).toBe( + head.linkId + ) + } + }) + vi.spyOn(cliTranscript, 'prepareStructuredChatCliTranscript').mockResolvedValue({ + useRealCodexHome: false, + cliForkSource: { + agent: 'codex', + sourceAgent: 'codex', + sessionId: nativeId, + filePath, + cwd: rig.root, + codexHome, + executionHostPlatform: process.platform + } + }) +} diff --git a/tests/e2e/mobile-terminal-session-switch.unit.test.ts b/tests/e2e/mobile-terminal-session-switch.unit.test.ts new file mode 100644 index 000000000000..987001526c3a --- /dev/null +++ b/tests/e2e/mobile-terminal-session-switch.unit.test.ts @@ -0,0 +1,220 @@ +import type { StoreApi } from 'zustand' +import type { AppState } from '../../src/renderer/src/store/types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { publishAgentLaunchTab as windowPublish } from '@/lib/agent-launch-tab-publication' +import { applyAgentLaunchPaneVerdict } from '@/lib/agent-launch-pane-verdict-application' +import { agentSessionSwitchDeadline } from '../../src/shared/agent-session-switch-execution' +import { afterEach, expect, it, vi } from 'vitest' +import '../../src/main/runtime/orca-runtime-test-lifecycle.spec' +import { + OrcaRuntimeService, + electronMocks +} from '../../src/main/runtime/orca-runtime-test-mocks.spec' +import { + TEST_WORKTREE_ID as wt, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../../src/main/runtime/orca-runtime-test-fixtures.spec' +import { withChatTabChanges } from '../../src/main/runtime/agent-session/agent-session-tab-layout' +import { committingSpawn } from '../../src/main/runtime/bind-first-pane.test-fixture' +import type { RuntimeNotifier } from '../../src/main/runtime/runtime-notifier-contract' +import '../../src/main/daemon/mock-descendant-sweep' +import type { RuntimePtyController } from '../../src/main/runtime/runtime-pty-controller-contract' + +const renderer = vi.hoisted(() => { + const ref: { store: StoreApi | null } = { store: null } + return ref +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => renderer.store!.getState(), + setState: (...args: Parameters['setState']>) => + renderer.store!.setState(...args) + } +})) +vi.mock('@/hooks/ipc-events/terminal-command-state', () => ({ + focusTerminalInitiatedTab: () => {} +})) + +vi.mock('../../src/shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false })) + +type Spawn = NonNullable + +async function setup(outcome: 'ready' | 'failed' = 'ready', create?: Spawn) { + createTabsSliceMockApi() + renderer.store = createTestStore() + renderer.store.getState().setActiveWorktree(wt) + renderer.store.getState().createUnifiedTab(wt, 'agent-session', { + id: 'custom-source', + entityId: 'source-chat', + agentSessionAgent: 'codex' + }) + const initial = makeWorkspaceSessionWithHeadlessTerminal() + const session = + withChatTabChanges('local', initial, wt, [ + { open: { sessionId: 'source-chat', agent: 'codex', tabId: 'custom-source' } } + ]) ?? initial + const fixture = makeRuntimeStoreWithWorkspaceSession(session) + const service = new OrcaRuntimeService(fixture.runtimeStore) + const events: string[] = [] + const spawn = vi.fn(async (options: Parameters[0]) => { + events.push('spawn') + if (outcome === 'failed') { + throw new Error('spawn rejected') + } + if (create) { + return create(options) + } + return { id: 'pty-switch', incarnationId: '45454545-4545-4545-8545-454545454545' } + }) + service.setPtyController({ + spawn: committingSpawn(service, spawn), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, setBackgroundThrottling: vi.fn() } + }) + service.attachWindow(1) + service.syncWindowGraph(1, { + tabs: [], + leaves: [], + conditionalSwitchProjection: 1, + rendererGeneration: 'window-test' + }) + const publish = vi.spyOn(service, 'publishAgentLaunchTab').mockImplementation(async (request) => { + events.push('published') + expect(request.placement?.afterTabId).toBe('custom-source') + expect(request.viewer).toBe('focus-in-workspace') + expect(fixture.getSession().terminalLayoutsByTabId[request.tabId]).toBeUndefined() + return windowPublish({ ...request, requestId: 'window-publication' }) + }) + const report = vi.spyOn(service, 'reportAgentLaunchPaneVerdict') + const reveal = vi.fn>( + (_workspace, opts) => ({ + tabId: opts.tabId!, + identity: { worktreeId: wt, tabId: opts.tabId!, leafId: opts.leafId!, ptyId: opts.ptyId } + }) + ) + const { createMobileCreateTestNotifier } = + await import('../../src/main/runtime/orca-runtime-test-scenario-builders.spec') + service.setNotifier({ + ...createMobileCreateTestNotifier(vi.fn()), + revealTerminalSession: reveal, + publishAgentLaunchTab: async (request) => + windowPublish({ ...request, requestId: 'window-verdict' }), + agentLaunchPaneVerdict: applyAgentLaunchPaneVerdict + }) + service.projectStructuredAgentSessionTab({ + workspaceId: wt, + sessionId: 'source-chat', + agent: 'codex', + activate: true + }) + return { service, fixture, events, spawn, publish, report, reveal } +} +const deadlines: ReturnType[] = [] +afterEach(() => { + for (const deadline of deadlines) { + deadline.dispose() + } +}) +function execution() { + const deadline = agentSessionSwitchDeadline() + deadlines.push(deadline) + return { + ...deadline, + invocationId: 'click-1', + viewer: 'focus-in-workspace' as const + } +} +const args = () => ({ + agent: 'codex' as const, + invocationId: 'click-1', + sessionId: 'source-chat', + afterTabId: 'custom-source', + commandOverride: 'my-codex', + options: { model: 'gpt-5.4', effort: 'high' }, + execution: execution() +}) + +const nativeId = '019aef12-1234-7000-8000-123456789abc' +it.each([false, true])( + 'publishes before ordinary startup and preserves fresh/saved caller choices: saved=%s', + async (saved) => { + const { service, events, spawn, publish } = await setup() + const created = await service.createSessionSwitchTerminal(wt, { + ...args(), + ...(saved + ? { + source: { + agent: 'codex' as const, + sourceAgent: 'codex' as const, + sessionId: nativeId, + filePath: '/tmp/rollout.jsonl', + cwd: '/repo', + codexHome: null, + executionHostPlatform: process.platform + } + } + : {}) + }) + expect(events.slice(0, 2)).toEqual(['published', 'spawn']) + expect(publish).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('my-codex'), + launchAgent: 'codex' + }) + ) + if (saved) { + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining(nativeId), + resumeProviderSession: expect.objectContaining({ id: nativeId }), + launchRuntime: expect.any(Object) + }) + ) + expect(spawn.mock.calls[0]?.[0].command).not.toContain('fork') + } else { + expect(spawn.mock.calls[0]?.[0]).not.toHaveProperty('resumeProviderSession') + } + expect(created.tab.terminal).toBeDefined() + } +) + +it.each([false, true])( + 'withdraws a failed unused destination and retains one the user typed into: used=%s', + async (used) => { + const { service, publish, spawn, report } = await setup('failed') + const original = publish.getMockImplementation()! + publish.mockImplementation(async (request) => { + const receipt = await original(request) + if (!receipt) { + throw new Error('missing window publication') + } + if (used) { + const key = `${request.tabId}:${request.leafId}` + renderer.store!.setState({ lastTerminalInputAtByPaneKey: { [key]: 1 } }) + } + return receipt + }) + await expect(service.createSessionSwitchTerminal(wt, args())).rejects.toThrow() + const published = await publish.mock.results[0]!.value + if (used) { + expect( + renderer.store!.getState().tabsByWorktree[wt]?.some((row) => row.id === published.tabId) + ).toBe(true) + } else { + expect(report).toHaveBeenCalledWith(expect.objectContaining({ worktreeId: wt }), { + kind: 'withdrawn' + }) + expect(renderer.store!.getState().terminalLayoutsByTabId[published.tabId]).toBeUndefined() + } + expect(spawn).toHaveBeenCalledOnce() + } +) diff --git a/tests/e2e/native-chat-switch-readable.unit.test.tsx b/tests/e2e/native-chat-switch-readable.unit.test.tsx new file mode 100644 index 000000000000..0face4c53834 --- /dev/null +++ b/tests/e2e/native-chat-switch-readable.unit.test.tsx @@ -0,0 +1,246 @@ +import { prepareReadableSwitchSource } from './helpers/native-chat-switch-readable-source' +import { createRestTestRig } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { HOST_TEST_SESSION } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { parseAgentJournalItemKey } from '../../src/shared/agent-session-journal-item-key' +import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { + setupMainOwnedWorkspace, + WORKTREE_ID, + TAB_ID, + LEAF_A, + PTY_A +} from '../../src/main/runtime/main-owned-workspace-runtime-fixture' +import { executeStructuredSessionSwitch } from '../../src/main/runtime/rpc/methods/structured-agent-session-switch-execution' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { NativeChatStructuredSession } from '../../src/renderer/src/components/native-chat/NativeChatStructuredSession' +import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + stubLayout, + stubResizeObserver, + deliverResizes +} from '../../src/renderer/src/components/native-chat/native-chat-windowing-test-harness' +import { resetStructuredAgentSessionReadOwnersForTests } from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../src/shared/agent-session-wire' +import type * as StructuredAgentSessionClientModule from '@/runtime/structured-agent-session-client' + +// @vitest-environment happy-dom + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + ( + await import('../../src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness') + ).createStructuredSessionMocks() +) +const feed = vi.hoisted(() => ({ cursor: { epoch: 'epoch', sequence: 1 } })) +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', async (actual) => ({ + ...(await actual()), + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: async ( + _target: unknown, + params: { sessionId: string }, + onEvent: (event: AgentSessionSubscribeEvent) => void + ) => { + onEvent({ + type: 'batch', + sessionId: params.sessionId, + batch: { + cursor: feed.cursor, + items: [], + removedItemIds: [], + submissions: [] + } + }) + return { unsubscribe: () => {} } + }, + subscribeStructuredAgentSessionStatus: async () => ({ unsubscribe: () => {} }), + supportsStructuredAgentSessionQuietRepeatedStop: async () => false +})) +vi.mock('../../src/renderer/src/components/native-chat/use-native-chat-font-size', () => + moduleFactories.useNativeChatFontSize() +) +vi.mock('../../src/renderer/src/components/native-chat/use-native-chat-file-link-context', () => + moduleFactories.useNativeChatFileLinkContext() +) +vi.mock('../../src/renderer/src/components/native-chat/use-native-chat-file-link-click', () => + moduleFactories.useNativeChatFileLinkClick() +) +vi.mock('../../src/renderer/src/components/native-chat/NativeChatComposer', () => + moduleFactories.nativeChatComposer() +) +vi.mock('../../src/renderer/src/components/native-chat/NativeChatApprovalCard', () => + moduleFactories.nativeChatApprovalCard() +) +vi.mock('../../src/renderer/src/components/native-chat/NativeChatQuestionCard', () => + moduleFactories.nativeChatQuestionCard() +) +const restores: (() => void)[] = [] +const disposals: (() => Promise)[] = [] +afterEach(async () => { + cleanup() + for (const dispose of disposals.splice(0)) { + await dispose() + } + setStructuredAgentSessionHost(null) + resetStructuredAgentSessionReadOwnersForTests() + resetStructuredSessionMocks() + for (const restore of restores.splice(0).toReversed()) { + restore() + } + vi.restoreAllMocks() +}) +it.each(['readable'] as const)( + 'completes one physical switch independently of slow provider readiness and renderer history: %s', + async () => { + const restoreLayout = stubLayout() + const restoreResize = stubResizeObserver() + restores.push(restoreLayout, restoreResize) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}) + })) + const rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + disposals.push(rig.dispose) + setStructuredAgentSessionHost(rig.host) + await prepareReadableSwitchSource(rig, WORKTREE_ID) + const { runtime, session } = setupMainOwnedWorkspace() + const incarnationId = '11111111-1111-4111-8111-111111111111' + runtime.registerPty(PTY_A, WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_A, incarnationId }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_A}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('missing captured source') + } + const exit = Promise.withResolvers() + const stopping = Promise.withResolvers() + disposals.unshift(async () => exit.resolve()) + const stopped = vi.fn(async (ptyId: string) => { + expect(ptyId).toBe(PTY_A) + stopping.resolve() + await exit.promise + await runtime.onPtyExit(PTY_A, 0, incarnationId, { hostExitConfirmed: true }) + return true + }) + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: stopped, + getForegroundProcess: async () => 'codex' + }) + const acquire = rig.adapter.acquire.getMockImplementation() + if (!acquire) { + throw new Error('missing provider acquisition') + } + const provider = Promise.withResolvers() + disposals.unshift(async () => provider.resolve()) + const providerReady = vi.fn() + rig.adapter.acquire.mockImplementation(async (...args) => { + await provider.promise + const acquired = await acquire(...args) + providerReady() + return acquired + }) + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const invocation = { + sessionId: HOST_TEST_SESSION, + execution: { + invocationId: 'root-readable', + source: { + terminal: source.handle, + expectedIncarnationId: incarnationId, + originalPresentation: source.presentation + } + } + } + const operation = executeStructuredSessionSwitch(invocation, { runtime }, 'chat') + await Promise.race([ + stopping.promise, + operation.then((result) => { + throw new Error(`switch finished before physical Stop: ${JSON.stringify(result)}`) + }) + ]) + expect(publish).not.toHaveBeenCalled() + expect(rig.adapter.acquire).not.toHaveBeenCalled() + exit.resolve() + const completed = await operation + expect(completed).toEqual({ status: 'completed', sessionId: HOST_TEST_SESSION }) + expect(stopped).toHaveBeenCalledOnce() + expect(publish).toHaveBeenCalledOnce() + expect(session().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(session().unifiedTabs?.[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ entityId: HOST_TEST_SESSION, contentType: 'agent-session' }) + ]) + ) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(providerReady).not.toHaveBeenCalled() + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + if (!page.ok || !page.page.liveCursor) { + throw new Error('missing imported history') + } + expect( + page.page.items + .filter(({ body }) => body.kind === 'message') + .map(({ itemId }) => parseAgentJournalItemKey(itemId)) + ).toEqual([expect.objectContaining({ provider: 'legacy', agent: 'codex', recordId: '#2' })]) + feed.cursor = page.page.liveCursor + const read = Promise.withResolvers() + mocks.call.mockImplementation(async (_target, method) => { + if (method === 'agentSession.history') { + return read.promise + } + return { ok: true, models: [], current: { model: '' } } + }) + render( + + + + + + ) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + expect.anything(), + 'agentSession.history', + expect.anything() + ) + ) + expect(screen.getByRole('status', { name: 'Loading chat' })).toBeTruthy() + await act(async () => { + read.resolve(page) + }) + await waitFor(() => { + act(() => deliverResizes()) + expect(screen.getByText('Readable before Codex is ready')).toBeTruthy() + }) + expect(providerReady).not.toHaveBeenCalled() + expect(stopped).toHaveBeenCalledOnce() + expect(publish).toHaveBeenCalledOnce() + expect( + await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + ).toMatchObject({ + ok: true + }) + provider.resolve() + await vi.waitFor(() => expect(providerReady).toHaveBeenCalledOnce()) + } +) diff --git a/tests/e2e/runtime-environment-switch-dispatch-clock.unit.test.ts b/tests/e2e/runtime-environment-switch-dispatch-clock.unit.test.ts new file mode 100644 index 000000000000..36e2422310cf --- /dev/null +++ b/tests/e2e/runtime-environment-switch-dispatch-clock.unit.test.ts @@ -0,0 +1,141 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { registerRuntimeEnvironmentSubscriptionHandlers } from '../../src/main/ipc/runtime-environments/runtime-environment-subscription-handlers' +import { subscribeRuntimeEnvironmentFromPreload } from '../../src/preload/runtime-environment-subscriptions' +import { sendSharedControlSubscription } from '../../src/shared/remote-runtime/control/remote-runtime-shared-control-send' +import type { SharedControlLogicalSubscription } from '../../src/shared/remote-runtime/control/remote-runtime-shared-control-types' +import { agentSessionSwitchDeadline } from '../../src/shared/agent-session-switch-execution' +import { callAbortableRuntimeEnvironment } from '../../src/renderer/src/runtime/abortable-runtime-environment-call' +import { stubRuntimeEnvironmentBridge } from '../../src/renderer/src/runtime/runtime-environment-bridge-fixture' + +// @vitest-environment happy-dom + +type SubscriptionIpc = Parameters[0] +type SubscriptionEvent = Parameters[1]>[1] +const mocks = vi.hoisted(() => ({ handle: vi.fn(), subscribe: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: vi.fn() } })) +vi.mock('../../src/shared/runtime-environment-store', () => ({ + resolveEnvironment: () => ({ id: 'paired', createdAt: 1, runtimeId: 'host' }) +})) +vi.mock( + '../../src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers', + () => ({ + isRuntimeEnvironmentManuallyDisconnected: () => false + }) +) +vi.mock('../../src/main/ipc/runtime-environments/runtime-environment-transport-generation', () => ({ + getRuntimeEnvironmentTransportGeneration: () => 1 +})) +vi.mock('../../src/main/ipc/runtime-environments/runtime-environment-transport-routing', () => ({ + subscribeRuntimeEnvironment: mocks.subscribe +})) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) +it.each(['ready after expiry', 'delayed sent receipt'] as const)( + 'uses actual main/shared-control dispatch authority with %s across preload', + async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const deadline = agentSessionSwitchDeadline(Date.now, 735_000) + const ready = Promise.withResolvers() + const queuedEvents: (() => void)[] = [] + let deliver: Parameters[1] | undefined + const wire = vi.fn(() => true) + const sender = { + id: 1, + isDestroyed: () => false, + on: vi.fn(), + removeListener: vi.fn(), + send: (_channel: string, payload: SubscriptionEvent) => { + queuedEvents.push(() => deliver?.(null, payload)) + } + } + registerRuntimeEnvironmentSubscriptionHandlers({ + getUserDataPath: () => '/unused', + remoteRuntimeSubscriptions: new Map(), + pendingSubscriptions: new Map(), + recordLayoutFrame: () => {} + }) + const handler = (channel: string) => + mocks.handle.mock.calls.find(([name]) => name === channel)?.[1] + const ipc: SubscriptionIpc = { + invoke: async (channel: string, args: unknown) => { + // Cancellation delivery is independently stalled; the main deadline must suffice. + if (channel.endsWith(':unsubscribe')) { + return new Promise(() => {}) + } + const call = handler(channel) + if (!call) { + throw new Error('missing handler') + } + return call({ sender }, args) + }, + send: vi.fn(), + on: (_channel: string, listener: typeof deliver) => { + deliver = listener + }, + removeListener: vi.fn() + } + mocks.subscribe.mockImplementation(async (_root, _id, method, params, _timeout, callbacks) => { + await ready.promise + const logical: SharedControlLogicalSubscription = { + requestId: 'request', + method, + params, + retainedParamsBytes: 0, + sent: false, + closed: false, + closeAfterReady: false, + remoteSubscriptionId: null, + callbacks: { + beforeSend: callbacks.beforeSend, + onResponse: (response) => callbacks.onEvent({ type: 'response', response }), + onError: (error) => + callbacks.onEvent({ type: 'error', code: error.code, message: error.message }), + onClose: callbacks.onClose + } + } + sendSharedControlSubscription({ + subscriptions: new Map([['request', logical]]), + subscription: logical, + deviceToken: 'fixture-token', + send: wire + }) + return { requestId: 'request', close: vi.fn(), sendBinary: () => false } + }) + stubRuntimeEnvironmentBridge({ + subscribe: ( + args: Parameters[1], + callbacks: Parameters[2] + ) => subscribeRuntimeEnvironmentFromPreload(ipc, args, callbacks), + cancelSubscription: () => new Promise(() => {}) + }) + await vi.advanceTimersByTimeAsync(700_000) + const sent = vi.fn((at?: number) => deadline.beforeSend(at)) + const call = callAbortableRuntimeEnvironment( + 'paired', + 'agentSession.switchToChat', + {}, + 735_000, + deadline.signal, + undefined, + undefined, + sent, + deadline.delegateSend() + ) + void call.catch(() => undefined) + expect(mocks.subscribe).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(36_000) + ready.resolve() + await Promise.resolve() + await Promise.resolve() + expect(wire).not.toHaveBeenCalled() + for (const send of queuedEvents.splice(0)) { + send() + } + await expect(call).rejects.toThrow() + deadline.dispose() + } +) diff --git a/tests/e2e/terminal-presentation-retirement.unit.test.ts b/tests/e2e/terminal-presentation-retirement.unit.test.ts new file mode 100644 index 000000000000..91c8e4f1353c --- /dev/null +++ b/tests/e2e/terminal-presentation-retirement.unit.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../src/shared/terminal-tab-close' +import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' +import { registerMobileAndTerminalCloseIpcBridge } from '@/hooks/ipc-events/mobile-terminal-close-ipc-bridge' +import { mountedOwner } from '@/components/terminal-pane/terminal-presentation-retirement.test-fixture' +import { + createHarness, + WORKTREE_ID, + TAB_ID, + LEAF_ID, + PTY_ID +} from '../../src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +beforeEach(() => { + localStorage.clear() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it.each([false, true])( + 'persists the actual renderer effects after caught disposal failure before one failed IPC receipt (write fails=%s)', + async (writeFails) => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const session = harness.getSession() + useAppStore.setState({ + tabsByWorktree: session.tabsByWorktree, + terminalLayoutsByTabId: session.terminalLayoutsByTabId + }) + const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Expected original') + } + const { presentation, incarnationId } = source + const owner = mountedOwner({ + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) + vi.spyOn(owner.source.terminal, 'dispose').mockImplementation(() => { + throw new Error('terminal dispose failed') + }) + let request: ((value: TerminalTabCloseRequest) => void) | undefined + let rejectReceipt = (_error: Error): void => {} + const receipt = new Promise((_resolve, reject) => { + rejectReceipt = reject + }) + const respond = vi.fn((response: TerminalTabCloseResponse) => { + rejectReceipt(new Error(response.error ?? 'Unexpected success')) + }) + const set = vi.fn(async (_state: WorkspaceSessionState) => { + expect(owner.manager.destroy).toHaveBeenCalledOnce() + expect(useAppStore.getState().terminalLayoutsByTabId[TAB_ID]).toBeUndefined() + if (writeFails) { + throw new Error('persistence failed') + } + }) + const flush = vi.fn(async () => {}) + Object.assign(window, { + api: { + ...window.api, + session: { set, flush }, + ui: { + onOpenFileFromMobile: () => () => {}, + onOpenDiffFromMobile: () => () => {}, + onCloseTerminal: () => () => {}, + onWorkspaceSleep: () => () => {}, + onResumeSleepingAgents: () => () => {}, + onTerminalTabCloseRequest: (listener: (value: TerminalTabCloseRequest) => void) => { + request = listener + return () => {} + }, + respondTerminalTabClose: respond + } + } + }) + const unsubs: (() => void)[] = [] + registerMobileAndTerminalCloseIpcBridge(unsubs, vi.fn()) + if (!request) { + throw new Error('Expected IPC listener') + } + const dispatch = request + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => { + dispatch({ + requestId: 'close', + tabId: TAB_ID, + localPtyTeardownOwnedExternally: true, + originalSurface: presentation.identity + }) + return receipt + }) + try { + await expect( + harness.runtime.closeTerminal(source.handle, incarnationId, presentation) + ).rejects.toThrow('terminal_tab_close_failed') + expect(harness.flushOrThrow).not.toHaveBeenCalled() + expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeUndefined() + await vi.waitFor(() => + expect(respond).toHaveBeenCalledExactlyOnceWith({ + requestId: 'close', + error: 'terminal_tab_close_failed' + }) + ) + expect(set).toHaveBeenCalledOnce() + expect(set.mock.calls[0][0].tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(flush).toHaveBeenCalledTimes(writeFails ? 0 : 1) + expect(owner.sourceTransport.detach).toHaveBeenCalledOnce() + } finally { + unsubs.forEach((unsubscribe) => unsubscribe()) + owner.unregister() + } + } +) diff --git a/tests/e2e/web-runtime-session-terminal-host-authority.unit.test.ts b/tests/e2e/web-runtime-session-terminal-host-authority.unit.test.ts index f3ce4ff5c8bb..f1f76662fe23 100644 --- a/tests/e2e/web-runtime-session-terminal-host-authority.unit.test.ts +++ b/tests/e2e/web-runtime-session-terminal-host-authority.unit.test.ts @@ -475,7 +475,7 @@ describe('createWebRuntimeSessionTerminal', () => { snapshot, ENVIRONMENT_ID, undefined, - undefined, + { offerOpenSelection: undefined }, mocks.changedAgentPaneKeys ) })