From 26cb1d06b759cba8b68e4e91d6834afc46f52b2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:41:51 -0700 Subject: [PATCH 1/8] fix(agent-launch): after a restart a launch pane is an ordinary pane, never "couldn't confirm" A launch pane's spawn read the launch record (and the tab's saved outcome) when no launch was running for it, so a row left pending by a quit showed "Couldn't confirm the agent started." The record never observes the process; the terminal host does. With no launch running in this process the pane now spawns exactly like any tab: a live agent reattaches, an exited or absent one gives a fresh shell, and a gone daemon shows the normal unavailable state. - the running launch tells its pane how it ended in memory (finish carries the verdict); only a definite failure says "couldn't start", an unknown outcome leaves an ordinary pane - deleted: agentLaunchPaneVerdictFromRecord, listAgentSessionOperationRowsOwningPane, the `unconfirmed` verdict and its notice string, the tab's persisted launch pane, ownedPane writes - rows written by an older build that carry ownedPane are ignored; they expire with the row --- .../agent-launch-pane-attachment.test.ts | 192 ++-------------- .../agent-launch-pane-attachment.ts | 168 +++++--------- .../ipc/pty-agent-launch-pane-attach.test.ts | 160 +------------- .../ipc/pty-agent-launch-pane-restart.test.ts | 206 ++++++++++++++++++ src/main/ipc/pty/ipc/spawn-begin.ts | 4 +- .../pane/agent-launch-pane-evidence.test.ts | 55 ----- .../pty/pane/agent-launch-pane-evidence.ts | 61 ++---- .../agent-session-operation-admission.ts | 10 +- .../mobile-session-tabs-part-07.spec.ts | 2 +- .../methods/agent-launch-execution-outcome.ts | 27 ++- .../methods/agent-launch-instant-tab.test.ts | 68 ++---- .../rpc/methods/agent-launch-replay.ts | 8 +- .../methods/agent-launch-tab-publication.ts | 72 +++--- src/main/runtime/rpc/methods/agent-launch.ts | 17 +- .../terminal-pane/AgentLaunchPaneNotice.tsx | 4 +- .../agent-launch-pane-notice-text.ts | 6 - .../agent-launch-pane-notice.test.ts | 10 +- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - ...nt-launch-pane-verdict-application.test.ts | 10 +- .../agent-launch-pane-verdict-application.ts | 5 +- .../lib/agent-launch-tab-publication.test.ts | 8 +- .../src/lib/agent-launch-through-host.test.ts | 2 - .../src/lib/agent-launch-through-host.ts | 12 +- .../launch-agent-new-tab-host-route.test.ts | 2 +- .../lib/launch-agent-new-tab-host-route.ts | 23 +- src/shared/agent-launch-pane-verdict.ts | 31 +-- .../agent-session-operation-ledger.test.ts | 45 ---- src/shared/agent-session-operation-ledger.ts | 42 +--- src/shared/terminal-tab-types.ts | 9 +- .../workspace-layout-disk-fields.ts | 5 +- .../workspace-layout-fact-locations.test.ts | 1 - .../workspace-layout-load-tabs.ts | 3 +- .../workspace-layout-model.ts | 7 +- .../workspace-layout-save-tabs.ts | 3 +- src/shared/workspace-session-schema.test.ts | 9 +- src/shared/workspace-session-schema.ts | 5 +- 41 files changed, 434 insertions(+), 864 deletions(-) create mode 100644 src/main/ipc/pty-agent-launch-pane-restart.test.ts delete mode 100644 src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts diff --git a/src/main/agent-launch/agent-launch-pane-attachment.test.ts b/src/main/agent-launch/agent-launch-pane-attachment.test.ts index f55317471c99..e04f05f40a4c 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.test.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.test.ts @@ -1,98 +1,36 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentLaunchResult } from '../../shared/agent-launch-intent' -import { - pendingAgentSessionOperationRow, - type AgentSessionOperationOutcome, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' +import { afterEach, describe, expect, it } from 'vitest' import { markAgentLaunchesClosedByUser, resetAgentLaunchPanesForTests, resolveAgentLaunchPaneVerdict, - trackRunningAgentLaunchPane, - type AgentLaunchPaneEvidence + trackRunningAgentLaunchPane } from './agent-launch-pane-attachment' const PANE = { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-1' } -const NOW = Date.now() -let rowCounter = 0 - -function row( - outcome: AgentSessionOperationOutcome, - overrides: Partial = {} -): AgentSessionOperationRow { - rowCounter += 1 - return { - ...pendingAgentSessionOperationRow({ - callerKey: 'caller', - operationId: `${NOW}-${rowCounter.toString(16).padStart(32, '0')}`, - fingerprint: 'fp', - now: NOW - }), - outcome, - ownedPane: PANE, - ...overrides - } -} - -function terminalLaunch(paneKey: string): AgentLaunchResult { - return { - outcome: { kind: 'terminal', handle: 'term_1', paneKey }, - worktreeId: 'wt-1', - receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } - } -} - -const SUCCEEDED_HERE: AgentSessionOperationOutcome = { - status: 'succeeded', - sessionId: '', - launch: terminalLaunch(PANE.paneKey) -} - -function evidence( - rows: AgentSessionOperationRow[] | null, - overrides: Partial = {} -): AgentLaunchPaneEvidence { - return { - isPaneLive: () => false, - openedRows: () => rows, - launchPaneOnTab: () => null, - openRows: async () => rows ?? [], - now: () => NOW, - ...overrides - } -} - -async function verdictFor(found: AgentLaunchPaneEvidence) { - return (await resolveAgentLaunchPaneVerdict(PANE, found)) ?? { kind: 'no-check' } -} +const notLive = (): boolean => false describe('a pane an agent launch laid out', () => { afterEach(() => { resetAgentLaunchPanesForTests() }) - it('costs a pane nothing launches into not even a wait', () => { - expect(resolveAgentLaunchPaneVerdict(PANE, evidence([]))).toBeNull() - expect( - resolveAgentLaunchPaneVerdict(PANE, evidence([row({ status: 'failed', code: 'x' })])) - ).not.toBeNull() - expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null))).toBeNull() + it('is an ordinary pane when no launch runs for it, as after a restart', () => { + expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull() }) - it('waits for the launch running in this process, then reads how it ended off the record', async () => { + it('waits for the launch running in this process, then says how it ended', async () => { const running = trackRunningAgentLaunchPane(PANE) - const rows: AgentSessionOperationRow[] = [] - const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence(rows)) + const verdict = resolveAgentLaunchPaneVerdict(PANE, notLive) expect(verdict).not.toBeNull() - rows.push(row({ status: 'failed', code: 'agent_session_exited_during_start' })) - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'not-started', code: 'agent_session_exited_during_start' }) await expect(verdict).resolves.toEqual({ kind: 'not-started', code: 'agent_session_exited_during_start' }) + // Over: the next spawn of this pane is an ordinary one. + expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull() }) it("hears the user's close only while the pane's launch is running", () => { @@ -117,111 +55,15 @@ describe('a pane an agent launch laid out', () => { it('never offers a shell in a tab the host is taking back', async () => { const running = trackRunningAgentLaunchPane(PANE) - const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence([])) - running.finish({ tabTakenBack: true }) + const verdict = resolveAgentLaunchPaneVerdict(PANE, notLive) + running.finish({ kind: 'withdrawn' }) await expect(verdict).resolves.toEqual({ kind: 'withdrawn' }) }) - it('attaches to a process that holds the pane, whatever the record says', async () => { - const live = { isPaneLive: () => true } - await expect(verdictFor(evidence([row({ status: 'unknown' })], live))).resolves.toEqual({ - kind: 'proceed' - }) - await expect( - verdictFor(evidence([row({ status: 'failed', code: 'x' })], live)) - ).resolves.toEqual({ kind: 'proceed' }) - }) - - it.each([ - [ - 'a failed launch: not started, with its reason', - [row({ status: 'failed', code: 'boom' })], - { kind: 'not-started', code: 'boom' } - ], - [ - 'an outcome nobody knows: unconfirmed, never a failure', - [row({ status: 'unknown' })], - { kind: 'unconfirmed' } - ], - [ - 'a launch that ran into another surface: withdrawn', - [row({ status: 'succeeded', sessionId: 's-1', launch: terminalLaunch('other:pane') })], - { kind: 'withdrawn' } - ], - [ - 'an agent that ran here and is gone: an ordinary terminal', - [row(SUCCEEDED_HERE)], - { kind: 'proceed' } - ], - [ - 'an expired row owns nothing', - [row({ status: 'failed', code: 'boom' }, { expiresAt: NOW - 1 })], - { kind: 'no-check' } - ], - [ - 'a refused second launch never overrides the one that ran here', - [ - row(SUCCEEDED_HERE, { recordedAt: NOW }), - row({ status: 'failed', code: 'agent_launch_pane_already_live' }, { recordedAt: NOW + 1 }) - ], - { kind: 'proceed' } - ], - [ - 'another pane of the same tab is not owned', - [ - row( - { status: 'failed', code: 'boom' }, - { ownedPane: { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-2' } } - ) - ], - { kind: 'no-check' } - ] - ] as const)('%s', async (_name, rows, expected) => { - await expect(verdictFor(evidence([...rows]))).resolves.toEqual(expected) - }) - - it('after a restart, opens the record for a pane whose launch was still open', async () => { - const restarted = evidence(null, { - launchPaneOnTab: () => ({}), - openRows: async () => [row({ status: 'unknown' })] - }) - await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'unconfirmed' }) - }) - - it("answers from the tab once its pane's fate is final, without the record and past its expiry", async () => { - const openRows = vi.fn(async () => []) - const restarted = evidence(null, { - launchPaneOnTab: () => ({ outcome: { kind: 'not-started', code: 'boom' } }), - openRows - }) - await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'not-started', code: 'boom' }) - expect(openRows).not.toHaveBeenCalled() - }) - - it('adopts a process its persisted binding names, even over a final "couldn\'t confirm"', async () => { - const survived = evidence(null, { - isPaneLive: () => true, - launchPaneOnTab: () => ({ outcome: { kind: 'unconfirmed' } }) - }) - await expect(verdictFor(survived)).resolves.toEqual({ kind: 'proceed' }) - }) - - it('settles a pane no record names as an ordinary terminal, so its tab can forget the launch', async () => { - const orphaned = evidence(null, { launchPaneOnTab: () => ({}), openRows: async () => [] }) - await expect(verdictFor(orphaned)).resolves.toEqual({ kind: 'proceed' }) - }) - - it('reads nothing for a pane whose tab keeps nothing and no record row names', () => { - const openRows = vi.fn(async () => []) - expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null, { openRows }))).toBeNull() - expect(openRows).not.toHaveBeenCalled() - }) - - it('leaves an ordinary terminal when the record cannot be read: bookkeeping never gates the pane', async () => { - const unreadable = evidence(null, { - launchPaneOnTab: () => ({}), - openRows: () => Promise.reject(new Error('journal_open_refused')) - }) - await expect(verdictFor(unreadable)).resolves.toEqual({ kind: 'proceed' }) + it('attaches to a process that holds the pane, whatever the launch says', async () => { + const running = trackRunningAgentLaunchPane(PANE) + const verdict = resolveAgentLaunchPaneVerdict(PANE, () => true) + running.finish({ kind: 'not-started', code: 'boom' }) + await expect(verdict).resolves.toEqual({ kind: 'proceed' }) }) }) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 25eef063db59..2bee62c8fee3 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -1,58 +1,54 @@ /** * What a pane may do when an `agent.launch` showed it before its agent existed. * - * Derived on every spawn. While the launch's fate is open, the launch record (the operation ledger) - * names the launch that owns the pane and how it ended; the runtime, and the pane's persisted - * binding, say whether a process holds it. Once the fate is final for the pane, the tab itself keeps - * it (`agentLaunchPane.outcome`) for the tab's life, so the pane no longer reads the record. The one - * in-memory fact is a launch still running in this process, which dies with that launch. Its pane - * attaches as soon as its agent runs; a user's close of its tab or pane, committed to main, stops it. + * Derived on every spawn, from this process alone: a launch still running here for the pane says + * how it ended, and whether a process holds the pane says the rest. With no launch running for the + * pane (after a restart, or once it ended) the pane is an ordinary pane: its spawn reattaches a live + * process, starts a fresh shell for an exited or absent one, or shows the terminal host's own + * unavailable state, exactly as any tab does. The launch record is never read for it. A user's close + * of its tab or pane, committed to main, stops a running launch. */ -import { - listAgentSessionOperationRowsOwningPane, - type AgentSessionOperationOwnedPane, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import { isAgentLaunchResult } from '../../shared/agent-launch-intent' -import type { - AgentLaunchPaneOutcome, - AgentLaunchPaneVerdict -} from '../../shared/agent-launch-pane-verdict' +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' +const PROCEED: AgentLaunchPaneVerdict = { kind: 'proceed' } + +/** A terminal pane a launch laid out before its process existed. */ +export type AgentLaunchPane = { worktreeId: string; paneKey: string } + type RunningLaunch = { - pane: AgentSessionOperationOwnedPane - /** The launch's agent holds the pane, or the launch is over: whichever comes first. */ - settled: Promise<{ tabTakenBack: boolean }> - finished: Promise<{ tabTakenBack: boolean }> + pane: AgentLaunchPane + /** The launch's agent holds the pane (`proceed`), or the launch is over: whichever comes first. */ + settled: Promise + /** What the launch left for the pane once over, if no process holds it. */ + finished: Promise /** The user closed the launch's tab or pane while it ran; set by main's commit of that close. */ closedByUser: boolean } const runningLaunchesByPane = new Map() -function paneKeyOf(pane: AgentSessionOperationOwnedPane): string { +function paneKeyOf(pane: AgentLaunchPane): string { return JSON.stringify([pane.worktreeId, pane.paneKey]) } export type RunningAgentLaunchPane = { /** The launch's agent runs in the pane: a waiting spawn attaches now, not once the prompt lands. */ agentBound(): void - /** The launch is over; its record says how. `tabTakenBack`: the host is closing the tab. */ - finish(outcome: { tabTakenBack: boolean }): void + /** The launch is over. `verdict` is what an unspawned pane does: `withdrawn` when the host takes + * the tab back, `not-started` when the launch failed, else `proceed`. */ + finish(verdict: AgentLaunchPaneVerdict): void /** The user closed this launch's tab or pane: the launch must not run, or must stop. */ closedByUser(): boolean } /** Registered before the window hears of the tab, so a pane that mounts at once already waits. */ -export function trackRunningAgentLaunchPane( - pane: AgentSessionOperationOwnedPane -): RunningAgentLaunchPane { +export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgentLaunchPane { const key = paneKeyOf(pane) - let resolve!: (outcome: { tabTakenBack: boolean }) => void - let settle!: (outcome: { tabTakenBack: boolean }) => void + let resolve!: (verdict: AgentLaunchPaneVerdict) => void + let settle!: (verdict: AgentLaunchPaneVerdict) => void const running: RunningLaunch = { pane, settled: new Promise((done) => { @@ -66,8 +62,8 @@ export function trackRunningAgentLaunchPane( runningLaunchesByPane.set(key, running) let finished = false return { - agentBound: () => settle({ tabTakenBack: false }), - finish: (outcome) => { + agentBound: () => settle(PROCEED), + finish: (verdict) => { if (finished) { return } @@ -75,8 +71,8 @@ export function trackRunningAgentLaunchPane( if (runningLaunchesByPane.get(key) === running) { runningLaunchesByPane.delete(key) } - settle(outcome) - resolve(outcome) + settle(verdict) + resolve(verdict) }, closedByUser: () => running.closedByUser } @@ -110,105 +106,43 @@ export function isAgentLaunchRunningIn( return runningLaunchesIn(worktreeId, target).length > 0 } -function launchRanInPane(row: AgentSessionOperationRow, paneKey: string): boolean { - if (row.outcome.status !== 'succeeded' || !isAgentLaunchResult(row.outcome.launch)) { - return false - } - const { outcome } = row.outcome.launch - return outcome.kind === 'terminal' && outcome.paneKey === paneKey -} - -/** The pane's fate as the record tells it. Exported for the record-only cases a test pins. */ -export function agentLaunchPaneVerdictFromRecord( - owning: readonly AgentSessionOperationRow[], - paneKey: string -): AgentLaunchPaneVerdict { - if (owning.length === 0 || owning.some((row) => launchRanInPane(row, paneKey))) { - // Nothing owns it, or an agent ran here and is gone: the pane is an ordinary terminal again. - return { kind: 'proceed' } - } - const latest = owning.reduce((a, b) => (b.recordedAt > a.recordedAt ? b : a)) - switch (latest.outcome.status) { - case 'failed': - return { kind: 'not-started', code: latest.outcome.code } - case 'succeeded': - // A chat, or another pane: nothing ran here. - return { kind: 'withdrawn' } - case 'unknown': - case 'pending': - return { kind: 'unconfirmed' } - } -} - -export type AgentLaunchPaneEvidence = { - /** A process holds the pane, live or by its persisted binding: the spawn adopts it, whatever the - * record says. */ - isPaneLive(paneKey: string): boolean - /** The record's rows when the store is already open; null when it is not. */ - openedRows(): Iterable | null - /** What the pane's tab keeps about its launch: null when no launch laid it out, no outcome while - * the fate is open, the outcome once it is final. */ - launchPaneOnTab(): { outcome?: AgentLaunchPaneOutcome } | null - openRows(): Promise> - now(): number -} +/** A process holds the pane, live or by its persisted binding: the spawn adopts it. */ +export type IsAgentLaunchPaneLive = (paneKey: string) => boolean async function settleVerdict( - pane: AgentSessionOperationOwnedPane, - evidence: AgentLaunchPaneEvidence + pane: AgentLaunchPane, + isPaneLive: IsAgentLaunchPaneLive ): Promise { - let waitedForLaunch = false - for (;;) { - const running = runningLaunchesByPane.get(paneKeyOf(pane)) - if (!running) { - break - } - waitedForLaunch = true - if ((await running.settled).tabTakenBack) { + let verdict = PROCEED + // A retry can start another launch into the same pane once the first is over. + for ( + let running = runningLaunchesByPane.get(paneKeyOf(pane)); + running; + running = runningLaunchesByPane.get(paneKeyOf(pane)) + ) { + if ((await running.settled).kind === 'withdrawn') { return { kind: 'withdrawn' } } - if (evidence.isPaneLive(pane.paneKey)) { - return { kind: 'proceed' } + if (isPaneLive(pane.paneKey)) { + return PROCEED } - if ((await running.finished).tabTakenBack) { - return { kind: 'withdrawn' } + verdict = await running.finished + if (verdict.kind === 'withdrawn') { + return verdict } } - if (evidence.isPaneLive(pane.paneKey)) { - return { kind: 'proceed' } - } - // A launch that just settled here wrote the record; the tab's saved outcome may be an earlier - // launch's, not yet replaced on disk. - const final = waitedForLaunch ? undefined : evidence.launchPaneOnTab()?.outcome - if (final) { - return final - } - // Bookkeeping never gates the user: a record that cannot be read leaves an ordinary terminal. - const rows = evidence.openedRows() ?? (await evidence.openRows().catch(() => null)) - return rows - ? agentLaunchPaneVerdictFromRecord( - listAgentSessionOperationRowsOwningPane(rows, pane, evidence.now()), - pane.paneKey - ) - : { kind: 'proceed' } + return isPaneLive(pane.paneKey) ? PROCEED : verdict } /** - * Null when nothing can own the pane — no launch running for it, its tab keeping nothing about a - * launch, and no record row naming it — so every other spawn keeps its timing and reads nothing. - * Otherwise the verdict, once any running launch is over. + * Null when no launch runs for the pane, so its spawn keeps every other pane's timing and reads + * nothing. Otherwise the verdict, once the running launch binds its agent or ends. */ export function resolveAgentLaunchPaneVerdict( - pane: AgentSessionOperationOwnedPane, - evidence: AgentLaunchPaneEvidence + pane: AgentLaunchPane, + isPaneLive: IsAgentLaunchPaneLive ): Promise | null { - if (!runningLaunchesByPane.has(paneKeyOf(pane)) && evidence.launchPaneOnTab() === null) { - const rows = evidence.openedRows() - if (!rows || listAgentSessionOperationRowsOwningPane(rows, pane, evidence.now()).length === 0) { - return null - } - } - return settleVerdict(pane, evidence) + return runningLaunchesByPane.has(paneKeyOf(pane)) ? settleVerdict(pane, isPaneLive) : null } export function resetAgentLaunchPanesForTests(): void { diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index 77cc18fd8607..2d78735ef325 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -7,15 +7,7 @@ import { resetAgentLaunchPanesForTests, trackRunningAgentLaunchPane } from '../agent-launch/agent-launch-pane-attachment' -import { - pendingAgentSessionOperationRow, - type AgentSessionOperationOutcome, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import { - AGENT_LAUNCH_PANE_REFUSED_CODE, - type AgentLaunchPaneOutcome -} from '../../shared/agent-launch-pane-verdict' +import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../shared/agent-launch-pane-verdict' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -100,19 +92,6 @@ describe('a pane whose process belongs to an agent launch', () => { resetAgentLaunchPanesForTests() }) - function recorded(outcome: AgentSessionOperationOutcome): AgentSessionOperationRow { - return { - ...pendingAgentSessionOperationRow({ - callerKey: 'caller', - operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, - fingerprint: 'fp', - now: Date.now() - }), - outcome, - ownedPane: pane - } - } - function mountPane(): Promise { return Promise.resolve( handlers.get('pty:spawn')!(null, { @@ -127,14 +106,8 @@ describe('a pane whose process belongs to an agent launch', () => { ) } - type LaunchPaneOnTab = { leafId: string; outcome?: AgentLaunchPaneOutcome } - - /** `rows` is the launch record; null models a process that has not opened it yet. `onTab` is - * what the restored tab keeps about its pane's launch. Returns the runtime the spawn reports to. */ - function registerWithRuntime( - providerSpawn: ReturnType, - record: { rows: AgentSessionOperationRow[] | null; onTab?: LaunchPaneOnTab } - ) { + /** Returns the runtime the spawn reports to. */ + function registerWithRuntime(providerSpawn: ReturnType) { setLocalPtyProvider(localProvider(providerSpawn)) const runtime = { setPtyController: vi.fn(), @@ -145,29 +118,13 @@ describe('a pane whose process belongs to an agent launch', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(), hasLiveTerminalForPaneKey: vi.fn(() => false), - openedAgentSessionRecordStore: vi.fn(() => - record.rows ? { listOperationRows: () => record.rows } : null - ), - openAgentSessionRecordStore: vi.fn(async () => ({ - listOperationRows: () => record.rows ?? [recorded({ status: 'unknown' })] - })), reportAgentLaunchPaneVerdict: vi.fn() } - // The persisted session as a restart restores it. - const store = record.onTab - ? { - getWorkspaceSession: () => ({ - tabsByWorktree: { [worktreeId]: [{ id: tabId, agentLaunchPane: record.onTab }] } - }) - } - : undefined // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches. const window = mainWindow as never // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime carrying only the members a renderer spawn reaches before it is refused or proceeds. const spawnRuntime = runtime as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a store carrying only the persisted session the launch pane's spawn reads. - const spawnStore = store as never - registerPtyHandlers(window, spawnRuntime, undefined, undefined, undefined, spawnStore) + registerPtyHandlers(window, spawnRuntime) return runtime } @@ -175,8 +132,7 @@ describe('a pane whose process belongs to an agent launch', () => { it('waits for the launch instead of spawning while it runs, then tells the window it settled', async () => { const providerSpawn = vi.fn(async () => ({ id: 'pty-after-launch' })) - const rows: AgentSessionOperationRow[] = [] - const runtime = registerWithRuntime(providerSpawn, { rows }) + const runtime = registerWithRuntime(providerSpawn) const running = trackRunningAgentLaunchPane(pane) const mounted = mountPane() @@ -188,20 +144,8 @@ describe('a pane whose process belongs to an agent launch', () => { expect(early).toBe('waiting') expect(providerSpawn).not.toHaveBeenCalled() - rows.push( - recorded({ - status: 'succeeded', - sessionId: '', - launch: { - outcome: { kind: 'terminal', handle: 'term_1', paneKey }, - worktreeId, - receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } - } - }) - ) - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'proceed' }) await expect(mounted).resolves.toMatchObject({ id: 'pty-after-launch' }) - // Settled: the tab forgets the launch, so a restart never opens the record for this pane. expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { kind: 'proceed' }) @@ -209,14 +153,12 @@ describe('a pane whose process belongs to an agent launch', () => { it('tells the window the launch failed, typed, instead of starting a shell', async () => { const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) - const rows: AgentSessionOperationRow[] = [] - const runtime = registerWithRuntime(providerSpawn, { rows }) + const runtime = registerWithRuntime(providerSpawn) const running = trackRunningAgentLaunchPane(pane) const mounted = mountPane() await new Promise((resolve) => setImmediate(resolve)) - rows.push(recorded({ status: 'failed', code: 'agent_session_exited_during_start' })) - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'not-started', code: 'agent_session_exited_during_start' }) await expect(mounted).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) expect(providerSpawn).not.toHaveBeenCalled() @@ -226,78 +168,9 @@ describe('a pane whose process belongs to an agent launch', () => { }) }) - it('still refuses a shell to a pane that mounts after the launch failed', async () => { - const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) - registerWithRuntime(providerSpawn, { - rows: [recorded({ status: 'failed', code: 'agent_not_installed' })] - }) - - await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) - expect(providerSpawn).not.toHaveBeenCalled() - }) - - it('after a restart, a pane whose launch was still open reads the record before it starts anything', async () => { - const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) - const runtime = registerWithRuntime(providerSpawn, { rows: null, onTab: { leafId } }) - - await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) - expect(providerSpawn).not.toHaveBeenCalled() - expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { - kind: 'unconfirmed' - }) - }) - - it('after a restart, a pane whose fate the tab keeps says it again without opening the record', async () => { - const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) - const runtime = registerWithRuntime(providerSpawn, { - rows: null, - onTab: { leafId, outcome: { kind: 'not-started', code: 'agent_not_installed' } } - }) - - await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) - expect(providerSpawn).not.toHaveBeenCalled() - expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() - }) - - it('a new launch into a pane that showed "couldn\'t confirm" lets it attach to the new agent', async () => { - const providerSpawn = vi.fn(async () => ({ id: 'pty-new-agent' })) - const rows: AgentSessionOperationRow[] = [] - // The saved tab still says an earlier launch could not be confirmed. - const runtime = registerWithRuntime(providerSpawn, { - rows, - onTab: { leafId, outcome: { kind: 'unconfirmed' } } - }) - const running = trackRunningAgentLaunchPane(pane) - - // The remounted pane spawns while the new launch runs: it waits, it does not show the old notice. - const mounted = mountPane() - rows.push( - recorded({ - status: 'succeeded', - sessionId: '', - launch: { - outcome: { kind: 'terminal', handle: 'term_1', paneKey }, - worktreeId, - receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } - } - }) - ) - running.finish({ tabTakenBack: false }) - - // Past the verdict the ordinary spawn path runs (this suite's store double saves nothing). - await mounted.catch(() => {}) - expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { - kind: 'proceed' - }) - expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalledWith(PANE_ADDRESS, { - kind: 'unconfirmed' - }) - expect(providerSpawn).toHaveBeenCalled() - }) - it("attaches once the launch's agent holds the pane, not once its prompt lands", async () => { const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) - const runtime = registerWithRuntime(providerSpawn, { rows: [] }) + const runtime = registerWithRuntime(providerSpawn) const running = trackRunningAgentLaunchPane(pane) const mounted = mountPane() @@ -311,25 +184,16 @@ describe('a pane whose process belongs to an agent launch', () => { expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { kind: 'proceed' }) - running.finish({ tabTakenBack: false }) - }) - - it('after a restart, a settled launch pane spawns as any pane does, without opening the record', async () => { - const providerSpawn = vi.fn(async () => ({ id: 'pty-ordinary' })) - const runtime = registerWithRuntime(providerSpawn, { rows: null }) - - await expect(mountPane()).resolves.toMatchObject({ id: 'pty-ordinary' }) - expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() - expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + running.finish({ kind: 'proceed' }) }) it('never offers a shell in a tab the host is taking back', async () => { const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) - const runtime = registerWithRuntime(providerSpawn, { rows: [] }) + const runtime = registerWithRuntime(providerSpawn) const running = trackRunningAgentLaunchPane(pane) const mounted = mountPane() - running.finish({ tabTakenBack: true }) + running.finish({ kind: 'withdrawn' }) await expect(mounted).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) expect(providerSpawn).not.toHaveBeenCalled() diff --git a/src/main/ipc/pty-agent-launch-pane-restart.test.ts b/src/main/ipc/pty-agent-launch-pane-restart.test.ts new file mode 100644 index 000000000000..5ab307519812 --- /dev/null +++ b/src/main/ipc/pty-agent-launch-pane-restart.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest' +import { pendingAgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { SessionNotFoundError } from '../daemon/daemon-errors' +import { makePaneKey } from '../../shared/stable-pane-id' +import { registerPtyHandlers } from './pty/register-handlers' +import { setLocalPtyProvider } from './pty/provider/registry' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +/** The daemon's three answers for a restored pane's binding, as `attachStablePaneOwner` reads them. */ +const DAEMON_ANSWERS = [ + ['live: the agent survived, so the pane reattaches to it', 'live'], + ['exited: the pane opens a fresh shell, as any tab does', 'exited'], + ['daemon gone: the terminal host is unavailable, as for any tab', 'gone'] +] as const + +describe('a launch pane after a restart', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + it.each(DAEMON_ANSWERS)( + '%s; an unsettled launch row naming it is never read', + async (_name, daemon) => { + const worktreeId = 'repo-1::/tmp/launch-restart' + const tabId = 'tab-launch-restart' + const leafId = '89898989-8989-4989-8989-898989898989' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean }) => { + if (!options.attachOnly) { + return { id: 'pty-fresh-shell', incarnationId: 'inc-fresh' } + } + if (daemon === 'live') { + return { id: 'pty-survived', incarnationId: 'inc-survived', isReattach: true } + } + throw daemon === 'exited' + ? new SessionNotFoundError('pty-survived') + : Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + syscall: 'connect' + }) + }) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + // Written by a build before this one: the tab kept its launch pane and the row named it. + let session = { + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + worktreeId, + ptyId: 'pty-survived', + agentLaunchPane: { leafId, outcome: { kind: 'unconfirmed' } } + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-survived' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-survived' } + } + const unsettledRow = { + ...pendingAgentSessionOperationRow({ + callerKey: 'caller', + operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp', + now: Date.now() + }), + outcome: { status: 'unknown' as const }, + ownedPane: { worktreeId, paneKey } + } + const store = withDurableRuntimeStore({ + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + }) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), + preAllocateHandleForPty: vi.fn(() => 'term-restart'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + hasLiveTerminalForPaneKey: vi.fn(() => false), + openedAgentSessionRecordStore: vi.fn(() => ({ listOperationRows: () => [unsettledRow] })), + openAgentSessionRecordStore: vi.fn(async () => ({ + listOperationRows: () => [unsettledRow] + })), + reportAgentLaunchPaneVerdict: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + const mounted = Promise.resolve( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/launch-restart', + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + }) + ) + + const ids = { live: 'pty-survived', exited: 'pty-fresh-shell' } as const + await (daemon === 'gone' + ? expect(mounted).rejects.toThrow('terminal_host_gone') + : expect(mounted).resolves.toMatchObject({ id: ids[daemon] })) + expect(runtime.openedAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index b856f6e129f6..355813c1c895 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -1,7 +1,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' -import { agentLaunchPaneEvidence } from '../pane/agent-launch-pane-evidence' +import { agentLaunchPaneLiveness } from '../pane/agent-launch-pane-evidence' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' @@ -48,7 +48,7 @@ export async function beginPtyIpcSpawn( launchPaneKey && args.worktreeId && args.tabId && args.leafId ? resolveAgentLaunchPaneVerdict( { worktreeId: args.worktreeId, paneKey: launchPaneKey }, - agentLaunchPaneEvidence(ctx.deps, { + agentLaunchPaneLiveness(ctx.deps, { worktreeId: args.worktreeId, tabId: args.tabId, leafId: args.leafId, diff --git a/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts b/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts deleted file mode 100644 index 9d8a782d9f84..000000000000 --- a/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { agentLaunchPaneEvidence } from './agent-launch-pane-evidence' - -const WT = 'repo-1::/tmp/evidence' -const TAB = 'tab-evidence' -const LEAF = '66666666-6666-4666-8666-666666666666' - -function storeWith(session: Record): never { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a store carrying only the persisted session these readers use. - return { getWorkspaceSession: () => session } as never -} - -const launchTab = { - id: TAB, - worktreeId: WT, - ptyId: null, - agentLaunchPane: { leafId: LEAF, outcome: { kind: 'unconfirmed' } } -} - -describe('what a launch pane spawn reads', () => { - it("counts a restored pane's persisted binding as a process that holds it", () => { - const evidence = agentLaunchPaneEvidence( - { - store: storeWith({ - tabsByWorktree: { [WT]: [launchTab] }, - terminalLayoutsByTabId: { [TAB]: { ptyIdsByLeafId: { [LEAF]: 'pty-survived' } } } - }) - }, - { worktreeId: WT, tabId: TAB, leafId: LEAF } - ) - expect(evidence.isPaneLive(makePaneKey(TAB, LEAF))).toBe(true) - }) - - it('reads the launch the tab keeps for this leaf only', () => { - const store = storeWith({ tabsByWorktree: { [WT]: [launchTab] } }) - expect( - agentLaunchPaneEvidence( - { store }, - { worktreeId: WT, tabId: TAB, leafId: LEAF } - ).launchPaneOnTab() - ).toEqual({ leafId: LEAF, outcome: { kind: 'unconfirmed' } }) - expect( - agentLaunchPaneEvidence( - { store }, - { worktreeId: WT, tabId: TAB, leafId: '77777777-7777-4777-8777-777777777777' } - ).launchPaneOnTab() - ).toBeNull() - expect( - agentLaunchPaneEvidence({ store }, { worktreeId: WT, tabId: TAB, leafId: LEAF }).isPaneLive( - makePaneKey(TAB, LEAF) - ) - ).toBe(false) - }) -}) diff --git a/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts b/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts index 98c0307b2a74..4986c8065261 100644 --- a/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts +++ b/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts @@ -1,58 +1,25 @@ -import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { AgentLaunchPaneEvidence } from '../../../agent-launch/agent-launch-pane-attachment' +import type { IsAgentLaunchPaneLive } from '../../../agent-launch/agent-launch-pane-attachment' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { resolveStablePaneOwner } from './stable-owner' -/** Reading the record is bookkeeping: a read that fails must leave the spawn as it was, never stop it. */ -function orElse(read: () => T, fallback: T): T { - try { - return read() - } catch { - return fallback - } -} - -/** What a pane's spawn reads to learn whether an agent launch owns it. */ -export function agentLaunchPaneEvidence( +/** Whether a process holds a launch pane: live, or by the persisted binding a restored pane whose + * agent survived in the daemon is adopted by. A read that fails leaves the spawn as it was. */ +export function agentLaunchPaneLiveness( deps: { runtime?: OrcaRuntimeService; store?: Store }, pane: { worktreeId: string; tabId: string; leafId: string; connectionId?: string | null } -): AgentLaunchPaneEvidence { +): IsAgentLaunchPaneLive { const { runtime, store } = deps const paneKey = makePaneKey(pane.tabId, pane.leafId) - return { - // The persisted binding counts: a restored pane whose agent survived in the daemon is adopted - // by the spawn below, never called unconfirmed. - isPaneLive: () => - orElse( - () => - (runtime?.hasLiveTerminalForPaneKey(paneKey) ?? false) || - resolveStablePaneOwner(runtime, store, paneKey, pane.worktreeId, pane.connectionId) !== - null, - false - ), - openedRows: () => - orElse(() => runtime?.openedAgentSessionRecordStore()?.listOperationRows() ?? null, null), - launchPaneOnTab: () => - orElse(() => { - if (!store || typeof store.getWorkspaceSession !== 'function') { - return null - } - const session = store.getWorkspaceSession( - pane.connectionId ? toSshExecutionHostId(pane.connectionId) : undefined - ) - const launchPane = session.tabsByWorktree?.[pane.worktreeId]?.find( - (candidate) => candidate.id === pane.tabId - )?.agentLaunchPane - return launchPane?.leafId === pane.leafId ? launchPane : null - }, null), - openRows: async () => { - if (!runtime) { - throw new Error('runtime_unavailable') - } - return (await runtime.openAgentSessionRecordStore()).listOperationRows() - }, - now: () => Date.now() + return () => { + try { + return ( + (runtime?.hasLiveTerminalForPaneKey(paneKey) ?? false) || + resolveStablePaneOwner(runtime, store, paneKey, pane.worktreeId, pane.connectionId) !== null + ) + } catch { + return false + } } } diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index a37b263d72a4..f2f1cdf9678d 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -13,7 +13,6 @@ import { type AgentSessionOperationClaim, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, - type AgentSessionOperationOwnedPane, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -231,7 +230,7 @@ export function admitAgentSessionGlobalOperationInto( export function claimAgentSessionOperationInto( state: { operations: Map }, - args: { callerKey: string; operationId: string; ownedPane?: AgentSessionOperationOwnedPane } + args: { callerKey: string; operationId: string } ): AgentSessionOperationClaim { const claimed = claimAgentSessionOperation(state.operations, args) state.operations = claimed.rows @@ -241,16 +240,11 @@ export function claimAgentSessionOperationInto( /** Whether an admission left the right to run open, so the same transaction should claim it. */ export type ClaimAfterAdmission = (decision: AgentSessionOperationDecision) => boolean -/** An admission whose claimant laid out a pane before its effect; recorded only if the claim wins. */ -export type AgentSessionOperationClaimingAdmission = AgentSessionOperationAdmission & { - ownedPane?: AgentSessionOperationOwnedPane -} - /** Admission and, when `claimAfter` says so, the claim, in one transaction: the same swap as * `claimAgentSessionOperationInto`, with one durable write instead of two. */ export function admitAndClaimAgentSessionOperationInto( state: { operations: Map }, - args: AgentSessionOperationClaimingAdmission, + args: AgentSessionOperationAdmission, claimAfter: ClaimAfterAdmission ): { decision: AgentSessionOperationDecision; claim: AgentSessionOperationClaim | null } { const decision = admitAgentSessionOperationInto(state, args) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts index b20afa3296e0..f1561f178d75 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts @@ -642,7 +642,7 @@ describe('OrcaRuntimeService', () => { ) expect(spawn).not.toHaveBeenCalled() } finally { - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'proceed' }) } }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts index be02a5acd338..9fc644aeff16 100644 --- a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts +++ b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts @@ -11,15 +11,21 @@ import { import type { RpcContext } from '../core' import { readsAgentLaunchTabClosed } from './agent-launch-replay' import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' +import { agentLaunchFailureCode } from './agent-launch-failure-code' export class AgentLaunchExecutionError extends Error { constructor( cause: unknown, - /** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */ - readonly failedWithoutEffects: boolean + /** The recorded code when the launch provably ran nothing, else null. Decided once, by the + * launch that ran; a later reader cannot re-derive it from the error. */ + readonly failureCode: string | null ) { super('agent_session_operation_unknown', { cause }) } + + get failedWithoutEffects(): boolean { + return this.failureCode !== null + } } export function settleQuietly(settlement: Promise): Promise { @@ -28,16 +34,23 @@ export function settleQuietly(settlement: Promise): Promise { }) } -/** Runs a launch whose tab may already be showing. Its pane reads how the launch ended off the - * launch record, which every path below settles before this returns. */ +/** Runs a launch whose tab may already be showing, and tells its pane how the launch ended: why, + * when it definitely failed; nothing when its outcome is unknown, so the pane is an ordinary one. */ export async function withEarlyTab( early: EarlyAgentLaunchTab | null, run: () => Promise ): Promise { try { - return await run() - } finally { + const result = await run() early?.finish() + return result + } catch (error) { + early?.finish( + error instanceof AgentLaunchExecutionError + ? (error.failureCode ?? undefined) + : agentLaunchFailureCode(error) + ) + throw error } } @@ -62,5 +75,5 @@ export async function settleLaunchWhoseTabWasClosed( await context.runtime.closeTerminal(handle).catch(() => {}) } await settleQuietly(admission.fail(AGENT_LAUNCH_TAB_CLOSED_CODE)) - throw new AgentLaunchExecutionError(new AgentLaunchTabClosedError(), true) + throw new AgentLaunchExecutionError(new AgentLaunchTabClosedError(), AGENT_LAUNCH_TAB_CLOSED_CODE) } 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 f5feeae8d079..633097b23f5a 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 @@ -26,8 +26,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { markAgentLaunchesClosedByUser, resetAgentLaunchPanesForTests, - resolveAgentLaunchPaneVerdict, - type AgentLaunchPaneEvidence + resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' import type { AgentLaunchPaneVerdict } from '../../../../shared/agent-launch-pane-verdict' import type { AgentSessionRecordStore } from '../../agent-session-record-store' @@ -183,29 +182,14 @@ function terminalOptions(runtime: Host): Record { return runtime.createTerminal.mock.calls[0]?.[1] ?? {} } -/** What the pane reads when it mounts: this host's runtime, and the launch record on disk. */ -function paneEvidence( - runtime: Host, - record: AgentSessionRecordStore = store -): AgentLaunchPaneEvidence { - return { - isPaneLive: (paneKey) => runtime.hasLiveTerminalForPaneKey(paneKey), - openedRows: () => record.listOperationRows(), - launchPaneOnTab: () => ({}), - openRows: async () => record.listOperationRows(), - now: () => Date.now() - } -} - +/** What the pane's spawn decides when it mounts; `unowned` is an ordinary pane's spawn. */ async function paneVerdict( runtime: Host, - record?: AgentSessionRecordStore, paneKey = PANE_KEY ): Promise { return ( - (await resolveAgentLaunchPaneVerdict( - { worktreeId: 'wt-7', paneKey }, - paneEvidence(runtime, record) + (await resolveAgentLaunchPaneVerdict({ worktreeId: 'wt-7', paneKey }, (key) => + runtime.hasLiveTerminalForPaneKey(key) )) ?? 'unowned' ) } @@ -285,16 +269,13 @@ describe('the instant tab', () => { } }) - it('records the pane it showed with the launch, and lets the pane attach once the agent runs', async () => { + it('lets the pane attach once the agent runs, and writes no pane onto the launch record', async () => { const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(runtime, { paneKey: PANE_KEY }, CLI) - expect(store.listOperationRows()[0]?.ownedPane).toEqual({ - worktreeId: 'wt-7', - paneKey: PANE_KEY - }) - await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + expect(store.listOperationRows()[0]).not.toHaveProperty('ownedPane') + await expect(paneVerdict(runtime)).resolves.toBe('unowned') expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() }) @@ -373,17 +354,13 @@ describe('the pane while its launch delivers the prompt', () => { }) describe('the pane after its launch', () => { - it('says a failed spawn did not start, keeps the tab, and says it again after a restart', async () => { + it('says a failed spawn did not start and keeps the tab; after a restart it is an ordinary pane', async () => { const runtime = hostWithWindow() failBeforeDispatch(runtime, 'spawn claude ENOENT') await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow('ENOENT') - await expect(paneVerdict(runtime)).resolves.toEqual({ - kind: 'not-started', - code: 'spawn claude ENOENT' - }) - // Kept, and told: the tab records the outcome even if its pane never mounts. + // Kept, and told: the tab keeps the outcome even if its pane never mounts. expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalledWith(expect.anything(), { kind: 'withdrawn' }) @@ -393,15 +370,12 @@ describe('the pane after its launch', () => { { kind: 'not-started', code: 'spawn claude ENOENT' } ) ) - // A restarted host: no launch in memory, the record read back from disk. + // A restarted host: no launch in memory, and the record is not read for the pane. resetAgentLaunchPanesForTests() - const reopened = await openTestAgentSessionRecordStore(directory) - await expect(paneVerdict(hostWithWindow(), reopened)).resolves.toMatchObject({ - kind: 'not-started' - }) + await expect(paneVerdict(hostWithWindow())).resolves.toBe('unowned') }) - it('says it cannot confirm a spawn whose outcome is unknown, and attaches if the agent turns up', async () => { + it('leaves an ordinary pane, never "couldn\'t confirm", when a spawn\'s outcome is unknown', async () => { const runtime = hostWithWindow() failAfterDispatch(runtime, 'daemon create timed out') @@ -409,9 +383,13 @@ describe('the pane after its launch', () => { 'agent_session_operation_unknown' ) - await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'unconfirmed' }) - runtime.hasLiveTerminalForPaneKey.mockReturnValue(true) - await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + await vi.waitFor(() => + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'proceed' } + ) + ) + await expect(paneVerdict(runtime)).resolves.toBe('unowned') }) it("never touches a running agent's pane when a second launch names it", async () => { @@ -430,7 +408,7 @@ describe('the pane after its launch', () => { expect(second.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() // Its agent exits and the pane remounts: still the first launch's pane, never "couldn't start". second.hasLiveTerminalForPaneKey.mockReturnValue(false) - await expect(paneVerdict(second)).resolves.toEqual({ kind: 'proceed' }) + await expect(paneVerdict(second)).resolves.toBe('unowned') }) it('takes back a tab nothing ran into without letting its waiting pane start a shell', async () => { @@ -443,7 +421,7 @@ describe('the pane after its launch', () => { refused.publishAgentLaunchTab.mockImplementation((request) => { const published = publish(request) // The pane mounts as soon as the tab exists. - verdicts.push(paneVerdict(refused, store, `${request.tabId}:${request.leafId}`)) + verdicts.push(paneVerdict(refused, `${request.tabId}:${request.leafId}`)) return published }) // The same id with other params: a conflict, so admission refuses and nothing runs. @@ -516,7 +494,7 @@ describe('retries', () => { expect(refused.published).toEqual([]) }) - it('with the record not yet open: a tab still shown keeps what its launch came to', async () => { + it('with the record not yet open: a tab still shown is an ordinary pane when its outcome is unknown', async () => { const operationId = nextOperationId() const first = hostWithWindow() failAfterDispatch(first, 'daemon create timed out') @@ -533,7 +511,7 @@ describe('retries', () => { await vi.waitFor(() => expect(retried.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, - { kind: 'unconfirmed' } + { kind: 'proceed' } ) ) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index 2a15b78fae6d..c4c1d14cc705 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -24,7 +24,6 @@ import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../../shared/agent-launch-ta import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' import type { AgentSessionOperationOutcome, - AgentSessionOperationOwnedPane, AgentSessionOperationRefusalCode } from '../../../../shared/agent-session-operation-ledger' import { resolveAgentSessionReplayOutcome } from '../../../native-chat/agent-session-wire/structured-agent-session-replay-outcome' @@ -195,10 +194,7 @@ export async function admitAgentLaunchOperation( context: RpcContext, params: AgentLaunchParams & { operationId: string }, fingerprint: string, - now: number = Date.now(), - // The pane the window already shows for this launch: the record names it so the pane can read - // how the launch ended, across a restart too. Written only if this request wins the claim. - ownedPane?: AgentSessionOperationOwnedPane + now: number = Date.now() ): Promise { const operationId = params.operationId const attachOperationId = deriveAgentLaunchChildOperationId(operationId) @@ -209,7 +205,7 @@ export async function admitAgentLaunchOperation( // The ledger alone: admitting a terminal launch has no use for the chat host. const store = await context.runtime.openAgentSessionRecordStore() const { decision: admitted, claim } = await store.admitAndClaimOperation( - { callerKey, operationId, fingerprint, now, ...(ownedPane ? { ownedPane } : {}) }, + { callerKey, operationId, fingerprint, now }, // A fresh row, or a replayed one no one has answered yet, leaves the right to run open. (decision) => decision.decision === 'admit' || 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 b20068d0835f..c023beb306ee 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -6,8 +6,9 @@ * wait for all of them. It now waits for none: the host asks the window for the tab first, under ids * the spawn will bake into the agent's PTY, and the pane attaches to that process when it exists. * - * Only a launch with an operation id gets one: the launch record is what tells the pane, then and - * after a restart, which launch owns it and how it ended (`agent-launch-pane-attachment`). + * Only a launch with an operation id gets one, so a retry finds the tab it already showed. While the + * launch runs, its pane learns from it how it ended (`agent-launch-pane-attachment`); after a restart + * the pane is an ordinary pane. * * Best-effort by construction. Anything this cannot decide cheaply — a workspace it cannot resolve, * a chat-mode default, no window owning the layout, a pane already running something — skips the @@ -24,20 +25,13 @@ import type { AgentLaunchTabPublished, AgentLaunchTabViewerRule } from '../../../../shared/agent-launch-tab-publication' -import { - listAgentSessionOperationRowsOwningPane, - type AgentSessionOperationOwnedPane -} from '../../../../shared/agent-session-operation-ledger' import { navigationTargetsHost, resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind' -import { - agentLaunchPaneVerdictFromRecord, - trackRunningAgentLaunchPane -} from '../../../agent-launch/agent-launch-pane-attachment' +import { trackRunningAgentLaunchPane } from '../../../agent-launch/agent-launch-pane-attachment' import type { AgentLaunchPaneVerdict } from '../../../../shared/agent-launch-pane-verdict' import { decideAgentLaunchMode, @@ -91,9 +85,7 @@ function readsEarlyLaunchTab(context: Pick try { publishing = runtime.publishAgentLaunchTab({ @@ -205,18 +197,17 @@ export async function publishAgentLaunchTabEarly( ...(params.operationId ? { operationId: params.operationId } : {}) }) } catch (error) { - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'proceed' }) throw error } if (!publishing) { - running.finish({ tabTakenBack: false }) + running.finish({ kind: 'proceed' }) return null } return trackEarlyAgentLaunchTab({ paneKey, - ownedPane, publishing, - finishRunning: (tabTakenBack) => running.finish({ tabTakenBack }), + finishRunning: (verdict) => running.finish(verdict), agentBound: () => running.agentBound(), paneIsLive: () => runtime.hasLiveTerminalForPaneKey(paneKey), closedByUser: () => running.closedByUser(), @@ -224,32 +215,18 @@ export async function publishAgentLaunchTabEarly( runtime.reportAgentLaunchPaneVerdict( { worktreeId: workspace.id, tabId: pane.tabId, leafId: pane.leafId }, verdict - ), - // What the record now says for a pane nothing spawned into, so it never stays blank. - unspawnedVerdict: () => - runtime.hasLiveTerminalForPaneKey(paneKey) - ? { kind: 'proceed' } - : agentLaunchPaneVerdictFromRecord( - listAgentSessionOperationRowsOwningPane( - runtime.openedAgentSessionRecordStore()?.listOperationRows() ?? [], - ownedPane, - Date.now() - ), - paneKey - ) + ) }) } function trackEarlyAgentLaunchTab(args: { paneKey: string - ownedPane: AgentSessionOperationOwnedPane publishing: Promise - finishRunning: (tabTakenBack: boolean) => void + finishRunning: (verdict: AgentLaunchPaneVerdict) => void agentBound: () => void paneIsLive: () => boolean closedByUser: () => boolean report: (verdict: AgentLaunchPaneVerdict) => void - unspawnedVerdict: () => AgentLaunchPaneVerdict }): EarlyAgentLaunchTab { let reply: AgentLaunchTabPublished | null = null let publicationFailed = false @@ -269,7 +246,6 @@ function trackEarlyAgentLaunchTab(args: { ) return { paneKey: args.paneKey, - ownedPane: args.ownedPane, executing: () => { executing = true }, @@ -282,10 +258,10 @@ function trackEarlyAgentLaunchTab(args: { }, windowOwnsPresentation: () => !publicationFailed, closedByUser: args.closedByUser, - finish: () => { + finish: (failureCode) => { if (ranHere === true) { // The agent's spawn bound the pane; its own spawn settles the tab. - args.finishRunning(false) + args.finishRunning({ kind: 'proceed' }) return } void published.then((answer) => { @@ -293,9 +269,17 @@ function trackEarlyAgentLaunchTab(args: { // a running agent holds (a replay can remake a tab whose agent survived). const nothingWillRunHere = !executing || ranHere === false const takeBack = answer?.created === true && nothingWillRunHere && !args.paneIsLive() - args.finishRunning(takeBack) - // Every shown pane nothing spawned into ends in a verdict: taken back, or what the record says. - args.report(takeBack ? { kind: 'withdrawn' } : args.unspawnedVerdict()) + // Every shown pane nothing spawned into ends in a verdict: taken back, or left by a launch + // that ran elsewhere, why its launch failed, or an ordinary terminal. + const live = args.paneIsLive() + const verdict: AgentLaunchPaneVerdict = + takeBack || (ranHere === false && !live) + ? { kind: 'withdrawn' } + : failureCode !== undefined && !live + ? { kind: 'not-started', code: failureCode } + : { kind: 'proceed' } + args.finishRunning(verdict) + args.report(verdict) }) }, placement: () => reply?.placement diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index d27947f11b4a..cd07fa6ff297 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -203,13 +203,7 @@ async function executeReplaySafeAgentLaunch( const early = await publishEarlyTab(params, context) let admission: Awaited> try { - admission = await admitAgentLaunchOperation( - context, - params, - fingerprint, - Date.now(), - early?.ownedPane - ) + admission = await admitAgentLaunchOperation(context, params, fingerprint, Date.now()) } catch (error) { early?.finish() throw error @@ -217,7 +211,12 @@ async function executeReplaySafeAgentLaunch( if (admission.decision !== 'execute') { // Nothing runs under this request. A tab it made goes, unless an agent still runs in its pane // (a replay can remake the tab of an agent that survived). - early?.finish() + early?.finish( + admission.decision === 'refuse' && + admission.refusal.code !== 'agent_session_operation_unknown' + ? admission.refusal.code + : undefined + ) if (admission.decision === 'refuse') { throw Object.assign(new Error(admission.refusal.code), { code: admission.refusal.code }) } @@ -269,7 +268,7 @@ async function executeAdmittedAgentLaunch( if (failedWithoutEffects) { await settleQuietly(admission.fail(failedWithoutEffects)) } - throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) + throw new AgentLaunchExecutionError(error, failedWithoutEffects) } if (view.early?.closedByUser()) { // The user closed its tab after the spawn left: the agent stops, as any closed tab's does. diff --git a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx index 51feff3b4234..7e6092839c01 100644 --- a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx +++ b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx @@ -6,8 +6,8 @@ import type { AgentLaunchPaneOutcome } from '../../../../shared/agent-launch-pan import { agentLaunchPaneNoticeText } from './agent-launch-pane-notice-text' /** - * What a pane an agent launch laid out shows when its agent is not running in it. Not dismissable: - * it is the pane's state, read again from the launch record on every mount, not a passing error. + * What a pane an agent launch laid out shows when its launch failed before its agent ran. Not + * dismissable: it is the pane's state for this session, not a passing error. */ export function AgentLaunchPaneNotice({ refusal, diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts index e624a2328d50..f01a9528044d 100644 --- a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts @@ -35,12 +35,6 @@ function notStartedReason(code: string): string | null { } export function agentLaunchPaneNoticeText(refusal: AgentLaunchPaneOutcome): string { - if (refusal.kind === 'unconfirmed') { - return translate( - 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed', - "Couldn't confirm the agent started." - ) - } const lead = translate( 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted', "The agent couldn't start." diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts index 55b9eef36d5e..6c2d421b577f 100644 --- a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' const CATALOG: Record = { 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted': '智能体无法启动。', - 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed': '无法确认智能体已启动。', 'auto.components.terminal.pane.AgentLaunchPaneNotice.exitedDuringStart': '它在启动时退出了。' } vi.mock('@/i18n/i18n', () => ({ @@ -24,17 +23,14 @@ describe("the pane of a launch whose agent isn't running", () => { agentLaunchPaneNoticeText({ kind: 'not-started', code: 'agent_launch_pane_already_live' }) ).toBe('智能体无法启动。') }) - - it("says it can't confirm, never that it failed, when nobody knows", () => { - expect(agentLaunchPaneNoticeText({ kind: 'unconfirmed' })).toBe('无法确认智能体已启动。') - }) }) describe('which pane shows it', () => { - const tab = { agentLaunchPane: { leafId: 'leaf-1', outcome: { kind: 'unconfirmed' as const } } } + const outcome = { kind: 'not-started' as const, code: 'boom' } + const tab = { agentLaunchPane: { leafId: 'leaf-1', outcome } } it('the leaf the launch laid out, once its fate is final', () => { - expect(agentLaunchPaneOutcomeForLeaf(tab, 'leaf-1')).toEqual({ kind: 'unconfirmed' }) + expect(agentLaunchPaneOutcomeForLeaf(tab, 'leaf-1')).toEqual(outcome) expect(agentLaunchPaneOutcomeForLeaf(tab, 'leaf-2')).toBeNull() expect( agentLaunchPaneOutcomeForLeaf({ agentLaunchPane: { leafId: 'leaf-1' } }, 'leaf-1') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 47918f63536f..44290c4e484b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3578,7 +3578,6 @@ "copyPrompt": "Copy prompt", "exitedDuringStart": "It exited while starting.", "notStarted": "The agent couldn't start.", - "unconfirmed": "Couldn't confirm the agent started.", "workspaceGone": "Its workspace is no longer available." }, "TerminalSleepingPaneOverlay": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ff33ef7f4d47..24a0aaa1fc8f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3347,7 +3347,6 @@ "copyPrompt": "Copiar prompt", "exitedDuringStart": "Se cerró mientras se iniciaba.", "notStarted": "El agente no pudo iniciarse.", - "unconfirmed": "No se pudo confirmar que el agente se iniciara.", "workspaceGone": "Su espacio de trabajo ya no está disponible." } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2b3bf9eca364..0c9e5ada7cc4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3452,7 +3452,6 @@ "copyPrompt": "Copier le prompt", "exitedDuringStart": "Il s'est arrêté pendant le démarrage.", "notStarted": "L'agent n'a pas pu démarrer.", - "unconfirmed": "Impossible de confirmer que l'agent a démarré.", "workspaceGone": "Son espace de travail n'est plus disponible." } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 47874e9d9f26..54ba4227d018 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3347,7 +3347,6 @@ "copyPrompt": "プロンプトをコピー", "exitedDuringStart": "起動中に終了しました。", "notStarted": "Agent を起動できませんでした。", - "unconfirmed": "Agent が起動したか確認できませんでした。", "workspaceGone": "ワークスペースは利用できなくなりました。" } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6688c78ee1f..e83f1f526431 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3347,7 +3347,6 @@ "copyPrompt": "프롬프트 복사", "exitedDuringStart": "시작하는 중에 종료되었습니다.", "notStarted": "에이전트를 시작하지 못했습니다.", - "unconfirmed": "에이전트가 시작되었는지 확인하지 못했습니다.", "workspaceGone": "작업 공간을 더 이상 사용할 수 없습니다." } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e33c7300ca05..6067dafd75ce 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3347,7 +3347,6 @@ "copyPrompt": "复制提示", "exitedDuringStart": "它在启动时退出了。", "notStarted": "智能体无法启动。", - "unconfirmed": "无法确认智能体已启动。", "workspaceGone": "其工作区已不可用。" } } 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 5e65a5b0a00f..7789df9cf0df 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 @@ -61,18 +61,18 @@ beforeEach(() => { }) describe("a launch pane's verdict in the window", () => { - it('settled: the tab forgets the launch and its prompt, so a restart reads nothing for it', () => { + it('settled: the tab forgets the launch and its prompt', () => { apply({ kind: 'proceed' }) expect(launchTab()?.agentLaunchPane).toBeUndefined() expect(agentLaunchPanePrompt(tabId)).toBeNull() }) - it('final: the tab keeps it for its life', () => { - apply({ kind: 'unconfirmed' }) + it('final: the tab keeps it for this session', () => { + apply({ kind: 'not-started', code: 'boom' }) expect(launchTab()?.agentLaunchPane).toEqual({ leafId: LEAF, operationId: 'op-1', - outcome: { kind: 'unconfirmed' } + outcome: { kind: 'not-started', code: 'boom' } }) expect(agentLaunchPanePrompt(tabId)).toBe('fix the build') }) @@ -122,7 +122,7 @@ describe('the user closing a launch tab', () => { }) it('is not, for a tab whose launch already has its outcome', () => { - apply({ kind: 'unconfirmed' }) + apply({ kind: 'not-started', code: 'boom' }) store.getState().closeTab(tabId) expect(wasAgentLaunchPaneClosedByUser(tabId, LEAF)).toBe(false) }) 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 75b9ebafc7db..845016866009 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -23,8 +23,7 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): forgetAgentLaunchPanePrompt(tabId) state.setTabAgentLaunchPane(tabId, undefined) return - case 'not-started': - case 'unconfirmed': { + case 'not-started': { const root = state.terminalLayoutsByTabId[tabId]?.root if (root && !collectLeafIdsInOrder(root).includes(leafId)) { // The user closed that pane: nothing is left for the outcome to describe. @@ -32,7 +31,7 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): state.setTabAgentLaunchPane(tabId, undefined) return } - // Final for this pane, for the tab's life: no later spawn needs the launch record. + // Final for this pane for this session. state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict }) return } 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 8d9ec4c3f0b8..0377b17dee0f 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -108,14 +108,14 @@ describe('publishing a launch tab before its agent exists', () => { store.getState().setTabAgentLaunchPane(TAB_ID, { leafId: LEAF_ID, operationId: 'op-1', - outcome: { kind: 'unconfirmed' } + outcome: { kind: 'not-started', code: 'boom' } }) publishAgentLaunchTab(request({ requestId: 'request-2', operationId: 'op-1' })) expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-1', - outcome: { kind: 'unconfirmed' } + outcome: { kind: 'not-started', code: 'boom' } }) publishAgentLaunchTab(request({ requestId: 'request-3', operationId: 'op-2' })) @@ -147,11 +147,11 @@ describe('publishing a launch tab before its agent exists', () => { const generation = () => store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.generation ?? 0 publishAgentLaunchTab(request({ operationId: 'op-1' })) - // Its spawn was refused ("couldn't confirm"): the pane is idle until it remounts. + // Its spawn was refused: the pane is idle until it remounts. store.getState().setTabAgentLaunchPane(TAB_ID, { leafId: LEAF_ID, operationId: 'op-1', - outcome: { kind: 'unconfirmed' } + outcome: { kind: 'not-started', code: 'boom' } }) const before = generation() 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 abccb529cc46..60ea51121236 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -167,7 +167,6 @@ describe('a desktop launch through the host', () => { await expect(outcome).resolves.toEqual({ kind: 'not-started', - unconfirmed: false, code: 'agent_session_operation_conflict' }) expect(launchTab(tabId)).toBeUndefined() @@ -194,7 +193,6 @@ describe('a desktop launch through the host', () => { await expect(outcome).resolves.toEqual({ kind: 'not-started', - unconfirmed: false, code: 'worktree_not_found' }) expect(launchTab(tabId)).toBeUndefined() diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 3ce247f95559..1d828450cce1 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -53,12 +53,12 @@ export type HostAgentLaunchArgs = { export type HostAgentLaunchOutcome = /** The agent was started in this tab's pane, which is attached to it. */ | { kind: 'started' } - /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ + /** The pane shows how the launch ended. */ | { kind: 'pane-says' } /** The user closed the tab while it started, and with it the launch: nothing more to say. */ | { kind: 'closed-by-user' } - /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ - | { kind: 'not-started'; unconfirmed: boolean; code?: string } + /** The tab is gone, so the window says nothing started there. */ + | { kind: 'not-started'; code?: string } /** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ const ADMISSION_REFUSAL_CODES = new Set([ @@ -125,11 +125,7 @@ async function settleLaunch( const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) if (!hostTookPane || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { closeLaunchTab(args.worktreeId, pane.tabId) - return { - kind: 'not-started', - unconfirmed: code === 'agent_session_operation_unknown', - ...(code ? { code } : {}) - } + return { kind: 'not-started', ...(code ? { code } : {}) } } return { kind: 'pane-says' } } finally { diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts index 54213717101b..ec552714f706 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -73,7 +73,7 @@ describe('an AI button launched through the host', () => { }) it('never pastes for a launch refused before the agent ran, and says so once', async () => { - deferredOutcome()({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' }) + deferredOutcome()({ kind: 'not-started', code: 'worktree_not_found' }) await expect(launch().promptDeliveryResult).resolves.toEqual({ delivered: false, diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts index 47a7f2675e2f..5f85e1979067 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -29,22 +29,15 @@ function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: str if (outcome.kind !== 'not-started') { return } - toast.error( - agentLaunchPaneNoticeText( - outcome.unconfirmed - ? { kind: 'unconfirmed' } - : { kind: 'not-started', code: outcome.code ?? '' } - ), - { - action: { - label: translate( - 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', - 'Copy prompt' - ), - onClick: () => void window.api.ui.writeClipboardText(prompt) - } + toast.error(agentLaunchPaneNoticeText({ kind: 'not-started', code: outcome.code ?? '' }), { + action: { + label: translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', + 'Copy prompt' + ), + onClick: () => void window.api.ui.writeClipboardText(prompt) } - ) + }) } /** diff --git a/src/shared/agent-launch-pane-verdict.ts b/src/shared/agent-launch-pane-verdict.ts index 5221e0cf8c1d..808d6ccd8dcf 100644 --- a/src/shared/agent-launch-pane-verdict.ts +++ b/src/shared/agent-launch-pane-verdict.ts @@ -4,14 +4,9 @@ * only fails with `AGENT_LAUNCH_PANE_REFUSED_CODE`, so nothing is parsed out of error text. */ -import { z } from 'zod' - -/** The launch's fate for its pane once nothing more can change it; the tab keeps it for its life. */ -export type AgentLaunchPaneOutcome = - /** The launch failed before its agent ran; `code` is the recorded reason, for the window to word. */ - | { kind: 'not-started'; code: string } - /** The launch may have started its agent and nothing can tell. */ - | { kind: 'unconfirmed' } +/** Why a launch pane shows no agent: the launch failed before its agent ran; `code` is the reason, + * for the window to word. Kept on the tab for this session only. */ +export type AgentLaunchPaneOutcome = { kind: 'not-started'; code: string } export type AgentLaunchPaneVerdict = /** No launch owns the pane, or its agent runs or ran there: the pane's own spawn decides. */ @@ -29,23 +24,3 @@ export type AgentLaunchPaneAddress = { worktreeId: string; tabId: string; leafId export type AgentLaunchPaneVerdictEvent = AgentLaunchPaneAddress & { verdict: AgentLaunchPaneVerdict } - -/** A tab's launch-pane state as persisted; a malformed one is dropped, never the session. */ -export const agentLaunchPaneOnTabSchema = z - .object({ - leafId: z.string(), - operationId: z.string().optional(), - outcome: z.custom(isAgentLaunchPaneOutcome).optional() - }) - .optional() - .catch(undefined) - -export function isAgentLaunchPaneOutcome(value: unknown): value is AgentLaunchPaneOutcome { - if (typeof value !== 'object' || value === null || !('kind' in value)) { - return false - } - return ( - value.kind === 'unconfirmed' || - (value.kind === 'not-started' && 'code' in value && typeof value.code === 'string') - ) -} diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index 7c4cd70e8561..ceeb8386efeb 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -6,10 +6,7 @@ import { import { agentSessionOperationExpiry, agentSessionOperationKey, - claimAgentSessionOperation, evaluateAgentSessionOperation, - pendingAgentSessionOperationRow, - settleAgentSessionOperation, isAgentSessionOperationRow, pruneAgentSessionOperationRows, type AgentSessionOperationRow @@ -143,48 +140,6 @@ describe('operation admission', () => { }) }) -describe('the pane a launch laid out', () => { - const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-1' } - - it('is recorded only by the claim that wins, and outlives the settle', () => { - const id = operationId(NOW) - const key = agentSessionOperationKey('caller', id) - const pending = new Map([ - [ - key, - pendingAgentSessionOperationRow({ - callerKey: 'caller', - operationId: id, - fingerprint: 'fp', - now: NOW - }) - ] - ]) - - const won = claimAgentSessionOperation(pending, { - callerKey: 'caller', - operationId: id, - ownedPane: pane - }) - expect(won.rows.get(key)?.ownedPane).toEqual(pane) - const lost = claimAgentSessionOperation(won.rows, { - callerKey: 'caller', - operationId: id, - ownedPane: { worktreeId: 'wt-1', paneKey: 'tab-2:leaf-2' } - }) - expect(lost.claim.claim).toBe('lost') - expect(lost.rows.get(key)?.ownedPane).toEqual(pane) - - const settled = settleAgentSessionOperation(won.rows, { - callerKey: 'caller', - operationId: id, - outcome: { status: 'failed', code: 'boom' } - }) - expect(settled.get(key)?.ownedPane).toEqual(pane) - expect(isAgentSessionOperationRow(settled.get(key))).toBe(true) - }) -}) - describe('retention', () => { it('keeps a tombstone strictly longer than its id can be admitted as new', () => { const expiry = agentSessionOperationExpiry(NOW, NOW) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 8c34edc7bcb0..b8b8b02ad6fa 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -69,9 +69,6 @@ export type AgentSessionOperationOutcome = /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } -/** A terminal pane an operation laid out before its process existed. */ -export type AgentSessionOperationOwnedPane = { worktreeId: string; paneKey: string } - export type AgentSessionOperationRow = { callerKey: string operationId: string @@ -80,37 +77,6 @@ export type AgentSessionOperationRow = { recordedAt: number expiresAt: number outcome: AgentSessionOperationOutcome - /** - * The pane an `agent.launch` showed before its agent existed. Written with the claim, so the pane - * can read its fate off this row — attach, couldn't start, couldn't confirm — across a restart, - * and stops being owned when the row expires. Not checked by `isAgentSessionOperationRow`: a - * malformed value costs that pane its verdict, never the row. - */ - ownedPane?: AgentSessionOperationOwnedPane -} - -/** Unexpired rows naming this pane as theirs. */ -export function listAgentSessionOperationRowsOwningPane( - rows: Iterable, - pane: AgentSessionOperationOwnedPane, - now: number -): AgentSessionOperationRow[] { - const owning: AgentSessionOperationRow[] = [] - for (const row of rows) { - const owned: unknown = row.ownedPane - if ( - row.expiresAt > now && - typeof owned === 'object' && - owned !== null && - 'paneKey' in owned && - 'worktreeId' in owned && - owned.paneKey === pane.paneKey && - owned.worktreeId === pane.worktreeId - ) { - owning.push(row) - } - } - return owning } export type AgentSessionOperationRefusalCode = @@ -198,7 +164,7 @@ export type AgentSessionOperationClaim = */ export function claimAgentSessionOperation( rows: ReadonlyMap, - args: { callerKey: string; operationId: string; ownedPane?: AgentSessionOperationOwnedPane } + args: { callerKey: string; operationId: string } ): { rows: Map; claim: AgentSessionOperationClaim } { const key = agentSessionOperationKey(args.callerKey, args.operationId) const existing = rows.get(key) @@ -208,11 +174,7 @@ export function claimAgentSessionOperation( if (existing.outcome.status !== 'pending') { return { rows: new Map(rows), claim: { claim: 'lost', row: existing } } } - const claimed: AgentSessionOperationRow = { - ...existing, - outcome: { status: 'unknown' }, - ...(args.ownedPane ? { ownedPane: args.ownedPane } : {}) - } + const claimed: AgentSessionOperationRow = { ...existing, outcome: { status: 'unknown' } } const next = new Map(rows) next.set(key, claimed) return { rows: next, claim: { claim: 'won', row: claimed } } diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 663966c6cf2d..4ddb1842ef00 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -95,11 +95,10 @@ export type TerminalTab = { * and manually-started agents omit it. */ launchAgent?: TuiAgent /** - * The pane an `agent.launch` laid out before its agent existed, while its fate is open or once it - * is final. Pending (no `outcome`): the pane's spawn reads the launch record. Cleared once the - * agent attaches or the pane is an ordinary terminal again. `outcome`: the launch never ran there, - * and the pane says so for the tab's life, until a different launch (`operationId`) reuses it; a - * retry of the same launch changes nothing. + * The pane an `agent.launch` laid out before its agent existed, for this session only. Cleared once + * the agent attaches or the pane is an ordinary terminal again. `outcome`: the launch never ran + * there, and the pane says so until a different launch (`operationId`) reuses it; a retry of the + * same launch changes nothing. */ agentLaunchPane?: { leafId: string; operationId?: string; outcome?: AgentLaunchPaneOutcome } /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index b627b8cddedb..24af88954515 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -129,9 +129,10 @@ const ROW = { forceHostRuntime: own('tab.terminal', 'forceHostRuntime'), startupCwd: own('tab.terminal', 'startupCwd'), launchAgent: own('tab.terminal', 'launchAgent'), - agentLaunchPane: own('tab.terminal', 'agentLaunchPane'), // Transient handoffs, never restored; the window strips them, but older builds' minimal row mint - // stored `pendingActivationSpawn`, so the Loader drops it. + // stored `pendingActivationSpawn`, so the Loader drops it. A launch pane is this session's: after + // a restart its pane spawns like any other. + agentLaunchPane: 'unwritten', pendingActivationSpawn: 'unwritten', recovery: 'unwritten', restoredFromSession: 'unwritten' diff --git a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts index 5c8de0f505e7..ce8523166aa8 100644 --- a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts +++ b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts @@ -121,7 +121,6 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { fill(loaded, 'tab.quickCommandLabel', 'build', terminal) fill(loaded, 'tab.isPreview', true, (holder) => holder.kind === 'editor') fill(loaded, 'tab.terminal.forceHostRuntime', true) - fill(loaded, 'tab.terminal.agentLaunchPane', { leafId: leaf(1) }) fill(loaded, 'file.externalSshTargetId', 'box') fill(loaded, 'file.runtimeEnvironmentId', 'env') fill(loaded, 'file.readOnly', true) diff --git a/src/shared/workspace-layout/workspace-layout-load-tabs.ts b/src/shared/workspace-layout/workspace-layout-load-tabs.ts index 799dc4e31ba0..66cb7cd00a83 100644 --- a/src/shared/workspace-layout/workspace-layout-load-tabs.ts +++ b/src/shared/workspace-layout/workspace-layout-load-tabs.ts @@ -14,8 +14,7 @@ const TERMINAL_CREATION_FIELDS = [ 'shellOverride', 'forceHostRuntime', 'startupCwd', - 'launchAgent', - 'agentLaunchPane' + 'launchAgent' ] as const satisfies readonly (keyof LayoutTerminalCreation)[] function contentFields(entry: Tab) { diff --git a/src/shared/workspace-layout/workspace-layout-model.ts b/src/shared/workspace-layout/workspace-layout-model.ts index 13be09bc2fc1..6fdab497e8b3 100644 --- a/src/shared/workspace-layout/workspace-layout-model.ts +++ b/src/shared/workspace-layout/workspace-layout-model.ts @@ -39,12 +39,7 @@ type LayoutTabFields = { /** Creation fields only a terminal tab carries. */ export type LayoutTerminalCreation = Pick< TerminalTab, - | 'defaultTitle' - | 'shellOverride' - | 'forceHostRuntime' - | 'startupCwd' - | 'launchAgent' - | 'agentLaunchPane' + 'defaultTitle' | 'shellOverride' | 'forceHostRuntime' | 'startupCwd' | 'launchAgent' > export type LayoutTerminalTab = LayoutTabFields & { diff --git a/src/shared/workspace-layout/workspace-layout-save-tabs.ts b/src/shared/workspace-layout/workspace-layout-save-tabs.ts index eab2130d0e00..7bc6e0a0a431 100644 --- a/src/shared/workspace-layout/workspace-layout-save-tabs.ts +++ b/src/shared/workspace-layout/workspace-layout-save-tabs.ts @@ -73,8 +73,7 @@ export function saveTerminalRow( 'shellOverride', 'forceHostRuntime', 'startupCwd', - 'launchAgent', - 'agentLaunchPane' + 'launchAgent' ]) } } diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index b9748b32e16b..2c5abbb5334a 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -169,7 +169,7 @@ describe('parseWorkspaceSession', () => { } }) - it("keeps a launch pane's state for the tab's life, and drops a malformed one", () => { + it('drops a launch pane an older build saved: after a restart its pane is an ordinary pane', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: null, @@ -205,11 +205,8 @@ describe('parseWorkspaceSession', () => { }) expect(result.ok).toBe(true) if (result.ok) { - expect(result.value.tabsByWorktree.wt[0].agentLaunchPane).toEqual({ - leafId: 'leaf-1', - outcome: { kind: 'not-started', code: 'boom' } - }) - expect(result.value.tabsByWorktree.wt[1].agentLaunchPane).toBeUndefined() + expect(result.value.tabsByWorktree.wt[0]).not.toHaveProperty('agentLaunchPane') + expect(result.value.tabsByWorktree.wt[1]).not.toHaveProperty('agentLaunchPane') } }) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index bf67f580a247..73fc668fd40b 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -11,7 +11,6 @@ * survives, because one bad tab record must not cost every worktree its state. * Only a payload that is not a session at all falls back to defaults. */ -import { agentLaunchPaneOnTabSchema } from './agent-launch-pane-verdict' import { z } from 'zod' import { closedTerminalTabTombstoneSchema } from './closed-terminal-tab-tombstones' import type { WorkspaceKey } from './folder-workspace-types' @@ -119,9 +118,7 @@ export const terminalTabSchema = z.object({ launchAgent: z .custom((v) => isTuiAgent(v)) .optional() - .catch(undefined), - // Why: survives a restart so a restored launch pane reads its fate before it spawns. - agentLaunchPane: agentLaunchPaneOnTabSchema + .catch(undefined) }) // ─── Unified tab model ────────────────────────────────────────────── From 4f4ea558e10d336aaac9f7e8706c58867a921215 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:09:08 -0700 Subject: [PATCH 2/8] test(agent-launch): the restart pane test joins the attach suite it shares mocks with --- .../ipc/pty-agent-launch-pane-attach.test.ts | 132 +++++++++++ .../ipc/pty-agent-launch-pane-restart.test.ts | 206 ------------------ 2 files changed, 132 insertions(+), 206 deletions(-) delete mode 100644 src/main/ipc/pty-agent-launch-pane-restart.test.ts diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index 2d78735ef325..ab1e1b6505a6 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { pendingAgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' @@ -202,3 +205,132 @@ describe('a pane whose process belongs to an agent launch', () => { }) }) }) + +/** The daemon's three answers for a restored pane's binding, as `attachStablePaneOwner` reads them. */ +const DAEMON_ANSWERS = [ + ['live: the agent survived, so the pane reattaches to it', 'live'], + ['exited: the pane opens a fresh shell, as any tab does', 'exited'], + ['daemon gone: the terminal host is unavailable, as for any tab', 'gone'] +] as const + +describe('a launch pane after a restart', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + it.each(DAEMON_ANSWERS)( + '%s; an unsettled launch row naming it is never read', + async (_name, daemon) => { + const worktreeId = 'repo-1::/tmp/launch-restart' + const tabId = 'tab-launch-restart' + const leafId = '89898989-8989-4989-8989-898989898989' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean }) => { + if (!options.attachOnly) { + return { id: 'pty-fresh-shell', incarnationId: 'inc-fresh' } + } + if (daemon === 'live') { + return { id: 'pty-survived', incarnationId: 'inc-survived', isReattach: true } + } + throw daemon === 'exited' + ? new SessionNotFoundError('pty-survived') + : Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + syscall: 'connect' + }) + }) + setLocalPtyProvider(localProvider(providerSpawn)) + // Written by a build before this one: the tab kept its launch pane and the row named it. + let session = { + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + worktreeId, + ptyId: 'pty-survived', + agentLaunchPane: { leafId, outcome: { kind: 'unconfirmed' } } + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-survived' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-survived' } + } + const unsettledRow = { + ...pendingAgentSessionOperationRow({ + callerKey: 'caller', + operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp', + now: Date.now() + }), + outcome: { status: 'unknown' as const }, + ownedPane: { worktreeId, paneKey } + } + const store = withDurableRuntimeStore({ + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + }) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), + preAllocateHandleForPty: vi.fn(() => 'term-restart'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + hasLiveTerminalForPaneKey: vi.fn(() => false), + openedAgentSessionRecordStore: vi.fn(() => ({ listOperationRows: () => [unsettledRow] })), + openAgentSessionRecordStore: vi.fn(async () => ({ + listOperationRows: () => [unsettledRow] + })), + reportAgentLaunchPaneVerdict: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches. + const window = mainWindow as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime and store carrying only the members a restored pane's spawn reaches. + const [spawnRuntime, spawnStore] = [runtime as never, store as never] + registerPtyHandlers(window, spawnRuntime, undefined, undefined, undefined, spawnStore) + + const mounted = Promise.resolve( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/launch-restart', + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + }) + ) + + const ids = { live: 'pty-survived', exited: 'pty-fresh-shell' } as const + await (daemon === 'gone' + ? expect(mounted).rejects.toThrow('terminal_host_gone') + : expect(mounted).resolves.toMatchObject({ id: ids[daemon] })) + expect(runtime.openedAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ipc/pty-agent-launch-pane-restart.test.ts b/src/main/ipc/pty-agent-launch-pane-restart.test.ts deleted file mode 100644 index 5ab307519812..000000000000 --- a/src/main/ipc/pty-agent-launch-pane-restart.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { pendingAgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' -import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { SessionNotFoundError } from '../daemon/daemon-errors' -import { makePaneKey } from '../../shared/stable-pane-id' -import { registerPtyHandlers } from './pty/register-handlers' -import { setLocalPtyProvider } from './pty/provider/registry' - -vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) -vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) -vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) -vi.mock('node:child_process', async (importOriginal) => - (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) -) -vi.mock('../opencode/hook-service', () => - import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) -) -vi.mock('../mimo/hook-service', () => - import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) -) -vi.mock('../agent-hooks/server', () => - import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) -) -vi.mock('../pi/titlebar-extension-service', () => - import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) -) -vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) -vi.mock('../wsl', async (importOriginal) => - (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) -) -vi.mock('../telemetry/client', () => - import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) -) -vi.mock('../telemetry/classify-error', () => - import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) -) -vi.mock('../cli/linux-terminal-orca-cli-shim', () => - import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) -) -vi.mock('../memory/pty-registry', () => - import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) -) -vi.mock('../agent-hooks/migration-unsupported-pty-state', () => - import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) -) -vi.mock('../codex/codex-pane-account-registry', () => - import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) -) -vi.mock('../codex/codex-state-db-backfill-recovery', () => - import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) -) - -/** The daemon's three answers for a restored pane's binding, as `attachStablePaneOwner` reads them. */ -const DAEMON_ANSWERS = [ - ['live: the agent survived, so the pane reattaches to it', 'live'], - ['exited: the pane opens a fresh shell, as any tab does', 'exited'], - ['daemon gone: the terminal host is unavailable, as for any tab', 'gone'] -] as const - -describe('a launch pane after a restart', () => { - const { handlers, mainWindow } = setupPtyIpcSuite() - - it.each(DAEMON_ANSWERS)( - '%s; an unsettled launch row naming it is never read', - async (_name, daemon) => { - const worktreeId = 'repo-1::/tmp/launch-restart' - const tabId = 'tab-launch-restart' - const leafId = '89898989-8989-4989-8989-898989898989' - const paneKey = makePaneKey(tabId, leafId) - const providerSpawn = vi.fn(async (options: { attachOnly?: boolean }) => { - if (!options.attachOnly) { - return { id: 'pty-fresh-shell', incarnationId: 'inc-fresh' } - } - if (daemon === 'live') { - return { id: 'pty-survived', incarnationId: 'inc-survived', isReattach: true } - } - throw daemon === 'exited' - ? new SessionNotFoundError('pty-survived') - : Object.assign(new Error('connect ECONNREFUSED'), { - code: 'ECONNREFUSED', - syscall: 'connect' - }) - }) - setLocalPtyProvider({ - spawn: providerSpawn, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown: vi.fn(), - sendSignal: vi.fn(), - getCwd: vi.fn(), - getInitialCwd: vi.fn(), - clearBuffer: vi.fn(), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(), - getForegroundProcess: vi.fn(), - serialize: vi.fn(), - revive: vi.fn(), - onData: vi.fn(() => () => {}), - onReplay: vi.fn(() => () => {}), - onExit: vi.fn(() => () => {}), - listProcesses: vi.fn(async () => []), - attach: vi.fn(), - getDefaultShell: vi.fn(), - getProfiles: vi.fn() - } as never) - // Written by a build before this one: the tab kept its launch pane and the row named it. - let session = { - tabsByWorktree: { - [worktreeId]: [ - { - id: tabId, - worktreeId, - ptyId: 'pty-survived', - agentLaunchPane: { leafId, outcome: { kind: 'unconfirmed' } } - } - ] - }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: 'pty-survived' } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-survived' } - } - const unsettledRow = { - ...pendingAgentSessionOperationRow({ - callerKey: 'caller', - operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, - fingerprint: 'fp', - now: Date.now() - }), - outcome: { status: 'unknown' as const }, - ownedPane: { worktreeId, paneKey } - } - const store = withDurableRuntimeStore({ - getWorkspaceSession: vi.fn(() => session), - setWorkspaceSession: vi.fn((next) => { - session = next - }), - flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn(), - getFolderWorkspace: vi.fn(() => undefined), - getFolderWorkspaces: vi.fn(() => []), - getProjectGroups: vi.fn(() => []), - getRepos: vi.fn(() => []) - }) - const runtime = { - setPtyController: vi.fn(), - resolveTerminalPane: vi.fn(() => { - throw new Error('terminal_not_found') - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), - preAllocateHandleForPty: vi.fn(() => 'term-restart'), - registerPreAllocatedHandleForPty: vi.fn(), - beginPtyRegistration: vi.fn(), - cancelPendingPtyRegistration: vi.fn(), - assertPtyRegistrationAllowed: vi.fn(), - registerPty: vi.fn(), - noteTerminalSpawnCommand: vi.fn(), - seedHeadlessTerminal: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn(), - hasLiveTerminalForPaneKey: vi.fn(() => false), - openedAgentSessionRecordStore: vi.fn(() => ({ listOperationRows: () => [unsettledRow] })), - openAgentSessionRecordStore: vi.fn(async () => ({ - listOperationRows: () => [unsettledRow] - })), - reportAgentLaunchPaneVerdict: vi.fn() - } - registerPtyHandlers( - mainWindow as never, - runtime as never, - undefined, - undefined, - undefined, - store as never - ) - - const mounted = Promise.resolve( - handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/tmp/launch-restart', - worktreeId, - tabId, - leafId, - env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } - }) - ) - - const ids = { live: 'pty-survived', exited: 'pty-fresh-shell' } as const - await (daemon === 'gone' - ? expect(mounted).rejects.toThrow('terminal_host_gone') - : expect(mounted).resolves.toMatchObject({ id: ids[daemon] })) - expect(runtime.openedAgentSessionRecordStore).not.toHaveBeenCalled() - expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() - expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() - } - ) -}) From f50f40cd94833a28aa18a7d70e7d4715002d5db9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:10:18 -0700 Subject: [PATCH 3/8] fix(agent-launch): a launch pane whose agent couldn't start keeps its notice for the session and never spawns a shell under it; a reload saves only a pending launch --- .../components/terminal-pane/ipc-pty-connect.ts | 13 +++++++++++++ .../pty-transport-connect-spawn.test.ts | 15 +++++++++++++++ .../agent-launch-pane-verdict-application.test.ts | 11 ++++++++++- .../lib/agent-launch-pane-verdict-application.ts | 12 ++++++++++++ .../src/lib/workspace-session-patch.test.ts | 11 +++++++++++ src/renderer/src/lib/workspace-session.ts | 6 +++++- src/shared/agent-launch-pane-verdict.ts | 9 +++++++++ .../workspace-layout-disk-fields.ts | 4 ++-- 8 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 121bd210b040..1cf2d9f28af1 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -16,6 +16,8 @@ import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold' +import { isAgentLaunchPaneOver } from '@/lib/agent-launch-pane-verdict-application' +import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -85,6 +87,17 @@ export async function connectIpcPty( return } } + // A launch pane whose agent couldn't start says so for this session, never over a shell. + if ( + !admittedSessionId && + isAgentLaunchPaneOver( + transportOptions.worktreeId, + transportOptions.tabId, + transportOptions.leafId + ) + ) { + throw new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) + } if (options.shouldContinue && !options.shouldContinue()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index ca8d7a816b4a..d6a0b0d8c464 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' +const isAgentLaunchPaneOver = vi.hoisted(() => vi.fn(() => false)) +vi.mock('@/lib/agent-launch-pane-verdict-application', () => ({ isAgentLaunchPaneOver })) + describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null @@ -128,6 +131,18 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) + // The same session: the pane mounts again after its launch failed, and keeps its notice, no shell. + it("never spawns a launch pane whose agent couldn't start", async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const pane = { worktreeId: 'wt', tabId: 'tab-over', leafId: 'leaf-over' } + isAgentLaunchPaneOver.mockReturnValueOnce(true) + const onError = vi.fn() + await createIpcPtyTransport(pane).connect({ url: '', callbacks: { onError } }) + + expect(isAgentLaunchPaneOver).toHaveBeenCalledWith('wt', 'tab-over', 'leaf-over') + expect(window.api.pty.spawn).not.toHaveBeenCalled() + }) + it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( 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 7789df9cf0df..6b32b36a3613 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 @@ -30,7 +30,8 @@ vi.mock('@/components/terminal-pane/closed-terminal-leaf-notice', () => ({ createTabsSliceMockApi() -const { applyAgentLaunchPaneVerdict } = await import('./agent-launch-pane-verdict-application') +const { applyAgentLaunchPaneVerdict, isAgentLaunchPaneOver } = + await import('./agent-launch-pane-verdict-application') const { agentLaunchPanePrompt, rememberAgentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') const { wasAgentLaunchPaneClosedByUser } = await import('./agent-launch-pane-closes') @@ -77,6 +78,14 @@ describe("a launch pane's verdict in the window", () => { expect(agentLaunchPanePrompt(tabId)).toBe('fix the build') }) + // As on main: a pane that mounts again this session keeps the notice and spawns no shell under it. + it('final: the pane is over for this session; pending or settled, it is not', () => { + expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(false) + apply({ kind: 'not-started', code: 'boom' }) + expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(true) + expect(isAgentLaunchPaneOver(WT, tabId, 'another-leaf')).toBe(false) + }) + it('final, for a launch pane the user already closed out of a split: nothing is kept', () => { store.getState().setTabLayout(tabId, { root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' }, 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 845016866009..cf69f19c9bca 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -9,6 +9,18 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layou import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +/** The tab keeps a final outcome for this pane: its notice stays, and the pane never spawns. */ +export function isAgentLaunchPaneOver( + worktreeId: string | undefined, + tabId: string | undefined, + leafId: string | undefined +): boolean { + const tab = worktreeId + ? useAppStore.getState().tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + : undefined + return tab?.agentLaunchPane?.leafId === leafId && tab.agentLaunchPane.outcome !== undefined +} + export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { const { worktreeId, tabId, leafId, verdict } = event const state = useAppStore.getState() diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 00c47f208593..836b62a54676 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -200,6 +200,11 @@ describe('buildWorkspaceSessionPatch', () => { ptyId: 'pty-1', worktreeId: localWorktreeId, pendingActivationSpawn: true, + agentLaunchPane: { + leafId: 'pane:1', + operationId: 'op-1', + outcome: { kind: 'not-started', code: 'boom' } + }, recovery: { attemptedAt: [1], generation: 1, @@ -246,6 +251,12 @@ describe('buildWorkspaceSessionPatch', () => { // Why: the recovery ledger describes a mounted pane's in-flight heal; a // persisted one would refuse the first legitimate recovery after restart. expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) + // A launch's outcome is this session's notice, so a reload spawns the pane as a restart does; + // a pending launch stays, so its verdict still lands after the reload. + expect(patch.tabsByWorktree![localWorktreeId][0].agentLaunchPane).toEqual({ + leafId: 'pane:1', + operationId: 'op-1' + }) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 6fb3509d1387..a27af150d725 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -1,3 +1,4 @@ +import { pendingAgentLaunchPane } from '../../../shared/agent-launch-pane-verdict' import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { PersistedOpenFile, @@ -204,6 +205,7 @@ export function buildSanitizedTabsByWorktree( ): WorkspaceSessionState['tabsByWorktree'] { // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. // Same for the recovery ledger: it describes a mounted pane's in-flight heal, so a persisted one would refuse the first recovery after restart. + // A launch pane's outcome is this session's notice, so a reload spawns it like a restart does; a pending launch stays, for its verdict. return Object.fromEntries( Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, @@ -217,7 +219,9 @@ export function buildSanitizedTabsByWorktree( void _unused void _recovery void _restored - return rest + return rest.agentLaunchPane + ? { ...rest, agentLaunchPane: pendingAgentLaunchPane(rest.agentLaunchPane) } + : rest }) ]) ) diff --git a/src/shared/agent-launch-pane-verdict.ts b/src/shared/agent-launch-pane-verdict.ts index 808d6ccd8dcf..7da0824ff33a 100644 --- a/src/shared/agent-launch-pane-verdict.ts +++ b/src/shared/agent-launch-pane-verdict.ts @@ -24,3 +24,12 @@ export type AgentLaunchPaneAddress = { worktreeId: string; tabId: string; leafId export type AgentLaunchPaneVerdictEvent = AgentLaunchPaneAddress & { verdict: AgentLaunchPaneVerdict } + +/** A launch pane as the window saves it: only a pending one, since its outcome is this session's. */ +export function pendingAgentLaunchPane( + pane: T +): Omit { + const { outcome: _outcome, ...pending } = pane + void _outcome + return pending +} diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index 24af88954515..becc1f773da9 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -130,8 +130,8 @@ const ROW = { startupCwd: own('tab.terminal', 'startupCwd'), launchAgent: own('tab.terminal', 'launchAgent'), // Transient handoffs, never restored; the window strips them, but older builds' minimal row mint - // stored `pendingActivationSpawn`, so the Loader drops it. A launch pane is this session's: after - // a restart its pane spawns like any other. + // stored `pendingActivationSpawn`, so the Loader drops it. A launch pane is this session's: the + // window saves only a pending one, for a reload, and after a restart its pane spawns like any other. agentLaunchPane: 'unwritten', pendingActivationSpawn: 'unwritten', recovery: 'unwritten', From 56a6c784a0b01e3e61259362675c6a95f8bc7573 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:13:43 -0700 Subject: [PATCH 4/8] test(agent-launch): drop assertions on code that is gone, and the taking-back case the IPC suite already pins --- .../agent-launch-pane-attachment.test.ts | 7 - .../ipc/pty-agent-launch-pane-attach.test.ts | 198 ++++++++---------- .../methods/agent-launch-instant-tab.test.ts | 3 +- 3 files changed, 85 insertions(+), 123 deletions(-) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.test.ts b/src/main/agent-launch/agent-launch-pane-attachment.test.ts index e04f05f40a4c..745b71d9191a 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.test.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.test.ts @@ -53,13 +53,6 @@ describe('a pane an agent launch laid out', () => { expect(running.closedByUser()).toBe(true) }) - it('never offers a shell in a tab the host is taking back', async () => { - const running = trackRunningAgentLaunchPane(PANE) - const verdict = resolveAgentLaunchPaneVerdict(PANE, notLive) - running.finish({ kind: 'withdrawn' }) - await expect(verdict).resolves.toEqual({ kind: 'withdrawn' }) - }) - it('attaches to a process that holds the pane, whatever the launch says', async () => { const running = trackRunningAgentLaunchPane(PANE) const verdict = resolveAgentLaunchPaneVerdict(PANE, () => true) diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index ab1e1b6505a6..d399b29d131b 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { pendingAgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' @@ -216,121 +215,92 @@ const DAEMON_ANSWERS = [ describe('a launch pane after a restart', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - it.each(DAEMON_ANSWERS)( - '%s; an unsettled launch row naming it is never read', - async (_name, daemon) => { - const worktreeId = 'repo-1::/tmp/launch-restart' - const tabId = 'tab-launch-restart' - const leafId = '89898989-8989-4989-8989-898989898989' - const paneKey = makePaneKey(tabId, leafId) - const providerSpawn = vi.fn(async (options: { attachOnly?: boolean }) => { - if (!options.attachOnly) { - return { id: 'pty-fresh-shell', incarnationId: 'inc-fresh' } - } - if (daemon === 'live') { - return { id: 'pty-survived', incarnationId: 'inc-survived', isReattach: true } - } - throw daemon === 'exited' - ? new SessionNotFoundError('pty-survived') - : Object.assign(new Error('connect ECONNREFUSED'), { - code: 'ECONNREFUSED', - syscall: 'connect' - }) - }) - setLocalPtyProvider(localProvider(providerSpawn)) - // Written by a build before this one: the tab kept its launch pane and the row named it. - let session = { - tabsByWorktree: { - [worktreeId]: [ - { - id: tabId, - worktreeId, - ptyId: 'pty-survived', - agentLaunchPane: { leafId, outcome: { kind: 'unconfirmed' } } - } - ] - }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: 'pty-survived' } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-survived' } - } - const unsettledRow = { - ...pendingAgentSessionOperationRow({ - callerKey: 'caller', - operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, - fingerprint: 'fp', - now: Date.now() - }), - outcome: { status: 'unknown' as const }, - ownedPane: { worktreeId, paneKey } + it.each(DAEMON_ANSWERS)('%s', async (_name, daemon) => { + const worktreeId = 'repo-1::/tmp/launch-restart' + const tabId = 'tab-launch-restart' + const leafId = '89898989-8989-4989-8989-898989898989' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean }) => { + if (!options.attachOnly) { + return { id: 'pty-fresh-shell', incarnationId: 'inc-fresh' } } - const store = withDurableRuntimeStore({ - getWorkspaceSession: vi.fn(() => session), - setWorkspaceSession: vi.fn((next) => { - session = next - }), - flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn(), - getFolderWorkspace: vi.fn(() => undefined), - getFolderWorkspaces: vi.fn(() => []), - getProjectGroups: vi.fn(() => []), - getRepos: vi.fn(() => []) - }) - const runtime = { - setPtyController: vi.fn(), - resolveTerminalPane: vi.fn(() => { - throw new Error('terminal_not_found') - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), - preAllocateHandleForPty: vi.fn(() => 'term-restart'), - registerPreAllocatedHandleForPty: vi.fn(), - beginPtyRegistration: vi.fn(), - cancelPendingPtyRegistration: vi.fn(), - assertPtyRegistrationAllowed: vi.fn(), - registerPty: vi.fn(), - noteTerminalSpawnCommand: vi.fn(), - seedHeadlessTerminal: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn(), - hasLiveTerminalForPaneKey: vi.fn(() => false), - openedAgentSessionRecordStore: vi.fn(() => ({ listOperationRows: () => [unsettledRow] })), - openAgentSessionRecordStore: vi.fn(async () => ({ - listOperationRows: () => [unsettledRow] - })), - reportAgentLaunchPaneVerdict: vi.fn() + if (daemon === 'live') { + return { id: 'pty-survived', incarnationId: 'inc-survived', isReattach: true } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches. - const window = mainWindow as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime and store carrying only the members a restored pane's spawn reaches. - const [spawnRuntime, spawnStore] = [runtime as never, store as never] - registerPtyHandlers(window, spawnRuntime, undefined, undefined, undefined, spawnStore) + throw daemon === 'exited' + ? new SessionNotFoundError('pty-survived') + : Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + syscall: 'connect' + }) + }) + setLocalPtyProvider(localProvider(providerSpawn)) + let session = { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-survived' }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-survived' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-survived' } + } + const store = withDurableRuntimeStore({ + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + }) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), + preAllocateHandleForPty: vi.fn(() => 'term-restart'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + hasLiveTerminalForPaneKey: vi.fn(() => false), + reportAgentLaunchPaneVerdict: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches. + const window = mainWindow as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime and store carrying only the members a restored pane's spawn reaches. + const [spawnRuntime, spawnStore] = [runtime as never, store as never] + registerPtyHandlers(window, spawnRuntime, undefined, undefined, undefined, spawnStore) - const mounted = Promise.resolve( - handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/tmp/launch-restart', - worktreeId, - tabId, - leafId, - env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } - }) - ) + const mounted = Promise.resolve( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/launch-restart', + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + }) + ) - const ids = { live: 'pty-survived', exited: 'pty-fresh-shell' } as const - await (daemon === 'gone' - ? expect(mounted).rejects.toThrow('terminal_host_gone') - : expect(mounted).resolves.toMatchObject({ id: ids[daemon] })) - expect(runtime.openedAgentSessionRecordStore).not.toHaveBeenCalled() - expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() - expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() - } - ) + const ids = { live: 'pty-survived', exited: 'pty-fresh-shell' } as const + await (daemon === 'gone' + ? expect(mounted).rejects.toThrow('terminal_host_gone') + : expect(mounted).resolves.toMatchObject({ id: ids[daemon] })) + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) }) 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 633097b23f5a..5a5ffe638ec3 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 @@ -269,12 +269,11 @@ describe('the instant tab', () => { } }) - it('lets the pane attach once the agent runs, and writes no pane onto the launch record', async () => { + it('lets the pane attach once the agent runs', async () => { const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(runtime, { paneKey: PANE_KEY }, CLI) - expect(store.listOperationRows()[0]).not.toHaveProperty('ownedPane') await expect(paneVerdict(runtime)).resolves.toBe('unowned') expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() }) From e85c1a5b161e60da6c2aa4543258624534dbebd6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:19:31 -0700 Subject: [PATCH 5/8] fix(agent-launch): narrow the finished-pane check, and test the saved session's launch pane with a typed tab --- .../agent-launch-pane-verdict-application.ts | 3 +- .../src/lib/workspace-session-patch.test.ts | 11 ------- .../src/lib/workspace-session.test.ts | 30 ++++++++++++++++++- 3 files changed, 31 insertions(+), 13 deletions(-) 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 cf69f19c9bca..b728cf2352eb 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -18,7 +18,8 @@ export function isAgentLaunchPaneOver( const tab = worktreeId ? useAppStore.getState().tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) : undefined - return tab?.agentLaunchPane?.leafId === leafId && tab.agentLaunchPane.outcome !== undefined + const pane = tab?.agentLaunchPane + return pane !== undefined && pane.leafId === leafId && pane.outcome !== undefined } export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 836b62a54676..00c47f208593 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -200,11 +200,6 @@ describe('buildWorkspaceSessionPatch', () => { ptyId: 'pty-1', worktreeId: localWorktreeId, pendingActivationSpawn: true, - agentLaunchPane: { - leafId: 'pane:1', - operationId: 'op-1', - outcome: { kind: 'not-started', code: 'boom' } - }, recovery: { attemptedAt: [1], generation: 1, @@ -251,12 +246,6 @@ describe('buildWorkspaceSessionPatch', () => { // Why: the recovery ledger describes a mounted pane's in-flight heal; a // persisted one would refuse the first legitimate recovery after restart. expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) - // A launch's outcome is this session's notice, so a reload spawns the pane as a restart does; - // a pending launch stays, so its verdict still lands after the reload. - expect(patch.tabsByWorktree![localWorktreeId][0].agentLaunchPane).toEqual({ - leafId: 'pane:1', - operationId: 'op-1' - }) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index e38660bffcc1..651ca871002c 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildWorkspaceSessionPayload } from './workspace-session' +import { buildSanitizedTabsByWorktree, buildWorkspaceSessionPayload } from './workspace-session' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { AppState } from '../store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' @@ -508,3 +509,30 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.activeTabTypeByWorktree).toEqual({ 'wt-2': 'terminal' }) }) }) + +describe('a launch pane in the saved session', () => { + const tab = (agentLaunchPane: TerminalTab['agentLaunchPane']): TerminalTab => ({ + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + agentLaunchPane + }) + + // A launch's outcome is this session's notice, so a reload spawns the pane as a restart does; + // a pending launch stays, so its verdict still lands after the reload. + it('keeps a pending launch and drops an outcome', () => { + const pending = { leafId: 'leaf-1', operationId: 'op-1' } + const saved = buildSanitizedTabsByWorktree({ + 'wt-1': [tab({ ...pending, outcome: { kind: 'not-started', code: 'boom' } })] + }) + expect(saved['wt-1'][0].agentLaunchPane).toEqual(pending) + expect( + buildSanitizedTabsByWorktree({ 'wt-1': [tab(pending)] })['wt-1'][0].agentLaunchPane + ).toEqual(pending) + }) +}) From 6bc00c3b927151e3d762cde916f98121ef714dac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:54:45 -0700 Subject: [PATCH 6/8] fix(agent-launch): a launch whose outcome is unknown still says "Couldn't confirm the agent started" for the session, and attaches its agent if it turns up --- .../agent-launch-pane-attachment.ts | 31 ++++++++++++++----- .../methods/agent-launch-execution-outcome.ts | 4 +-- .../methods/agent-launch-instant-tab.test.ts | 24 +++++++++----- .../methods/agent-launch-tab-publication.ts | 16 +++++----- .../terminal-pane/AgentLaunchPaneNotice.tsx | 4 +-- .../agent-launch-pane-notice-text.ts | 6 ++++ .../agent-launch-pane-notice.test.ts | 5 +++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...nt-launch-pane-verdict-application.test.ts | 8 +++++ .../agent-launch-pane-verdict-application.ts | 8 +++-- .../src/lib/agent-launch-through-host.test.ts | 2 ++ .../src/lib/agent-launch-through-host.ts | 12 ++++--- .../launch-agent-new-tab-host-route.test.ts | 2 +- .../lib/launch-agent-new-tab-host-route.ts | 23 +++++++++----- src/shared/agent-launch-pane-verdict.ts | 9 ++++-- 20 files changed, 115 insertions(+), 45 deletions(-) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 2bee62c8fee3..151644c2d197 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -2,10 +2,11 @@ * What a pane may do when an `agent.launch` showed it before its agent existed. * * Derived on every spawn, from this process alone: a launch still running here for the pane says - * how it ended, and whether a process holds the pane says the rest. With no launch running for the - * pane (after a restart, or once it ended) the pane is an ordinary pane: its spawn reattaches a live - * process, starts a fresh shell for an exited or absent one, or shows the terminal host's own - * unavailable state, exactly as any tab does. The launch record is never read for it. A user's close + * how it ended, and whether a process holds the pane says the rest. A launch whose outcome is + * unknown leaves the pane waiting for its agent until this process exits. Otherwise, with no launch + * running for the pane (after a restart, or once it ended) the pane is an ordinary pane: its spawn + * reattaches a live process, starts a fresh shell for an exited or absent one, or shows the terminal + * host's own unavailable state, exactly as any tab does. The launch record is never read for it. A user's close * of its tab or pane, committed to main, stops a running launch. */ @@ -29,6 +30,8 @@ type RunningLaunch = { } const runningLaunchesByPane = new Map() +/** Panes whose launch may have started its agent: each attaches if it turns up, else says so. */ +const unconfirmedPanes = new Set() function paneKeyOf(pane: AgentLaunchPane): string { return JSON.stringify([pane.worktreeId, pane.paneKey]) @@ -60,6 +63,7 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent closedByUser: false } runningLaunchesByPane.set(key, running) + unconfirmedPanes.delete(key) let finished = false return { agentBound: () => settle(PROCEED), @@ -70,6 +74,9 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent finished = true if (runningLaunchesByPane.get(key) === running) { runningLaunchesByPane.delete(key) + if (verdict.kind === 'unconfirmed') { + unconfirmedPanes.add(key) + } } settle(verdict) resolve(verdict) @@ -113,7 +120,9 @@ async function settleVerdict( pane: AgentLaunchPane, isPaneLive: IsAgentLaunchPaneLive ): Promise { - let verdict = PROCEED + let verdict: AgentLaunchPaneVerdict = unconfirmedPanes.has(paneKeyOf(pane)) + ? { kind: 'unconfirmed' } + : PROCEED // A retry can start another launch into the same pane once the first is over. for ( let running = runningLaunchesByPane.get(paneKeyOf(pane)); @@ -131,7 +140,11 @@ async function settleVerdict( return verdict } } - return isPaneLive(pane.paneKey) ? PROCEED : verdict + if (!isPaneLive(pane.paneKey)) { + return verdict + } + unconfirmedPanes.delete(paneKeyOf(pane)) + return PROCEED } /** @@ -142,9 +155,13 @@ export function resolveAgentLaunchPaneVerdict( pane: AgentLaunchPane, isPaneLive: IsAgentLaunchPaneLive ): Promise | null { - return runningLaunchesByPane.has(paneKeyOf(pane)) ? settleVerdict(pane, isPaneLive) : null + const key = paneKeyOf(pane) + return runningLaunchesByPane.has(key) || unconfirmedPanes.has(key) + ? settleVerdict(pane, isPaneLive) + : null } export function resetAgentLaunchPanesForTests(): void { runningLaunchesByPane.clear() + unconfirmedPanes.clear() } diff --git a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts index 9fc644aeff16..b35575572f09 100644 --- a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts +++ b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts @@ -46,9 +46,7 @@ export async function withEarlyTab( return result } catch (error) { early?.finish( - error instanceof AgentLaunchExecutionError - ? (error.failureCode ?? undefined) - : agentLaunchFailureCode(error) + error instanceof AgentLaunchExecutionError ? error.failureCode : agentLaunchFailureCode(error) ) throw error } 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 5a5ffe638ec3..a09e9a0050ad 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 @@ -374,7 +374,7 @@ describe('the pane after its launch', () => { await expect(paneVerdict(hostWithWindow())).resolves.toBe('unowned') }) - it('leaves an ordinary pane, never "couldn\'t confirm", when a spawn\'s outcome is unknown', async () => { + it('says it cannot confirm a spawn whose outcome is unknown, and attaches if the agent turns up', async () => { const runtime = hostWithWindow() failAfterDispatch(runtime, 'daemon create timed out') @@ -382,15 +382,25 @@ describe('the pane after its launch', () => { 'agent_session_operation_unknown' ) - await vi.waitFor(() => - expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( - { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, - { kind: 'proceed' } - ) - ) + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'unconfirmed' }) + runtime.hasLiveTerminalForPaneKey.mockReturnValue(true) + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + // Once its agent has bound, the pane is an ordinary one. + runtime.hasLiveTerminalForPaneKey.mockReturnValue(false) await expect(paneVerdict(runtime)).resolves.toBe('unowned') }) + it('after a restart, a pane whose outcome was unknown is an ordinary one', async () => { + const runtime = hostWithWindow() + failAfterDispatch(runtime, 'daemon create timed out') + await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow( + 'agent_session_operation_unknown' + ) + + resetAgentLaunchPanesForTests() + await expect(paneVerdict(hostWithWindow())).resolves.toBe('unowned') + }) + it("never touches a running agent's pane when a second launch names it", async () => { const first = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(first, { paneKey: PANE_KEY }, CLI) 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 c023beb306ee..935f59cf696b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -93,10 +93,10 @@ export type EarlyAgentLaunchTab = { windowOwnsPresentation(): boolean /** The user closed the tab or pane: the launch must not start its agent, or must stop it. */ closedByUser(): boolean - /** The launch is over; `failureCode` when it failed. A tab this request made goes when nothing - * will ever run in it: never admitted, or its surface landed elsewhere. A failed launch's tab - * stays to say why. */ - finish(failureCode?: string): void + /** The launch is over; `failureCode` when it failed, null when its outcome is unknown. A tab this + * request made goes when nothing will ever run in it: never admitted, or its surface landed + * elsewhere. A failed launch's tab stays to say why. */ + finish(failureCode?: string | null): void /** Where the window placed the tab, once it has said. */ placement(): AgentLaunchPlacementReceipt | undefined } @@ -275,9 +275,11 @@ function trackEarlyAgentLaunchTab(args: { const verdict: AgentLaunchPaneVerdict = takeBack || (ranHere === false && !live) ? { kind: 'withdrawn' } - : failureCode !== undefined && !live - ? { kind: 'not-started', code: failureCode } - : { kind: 'proceed' } + : failureCode === undefined || live + ? { kind: 'proceed' } + : failureCode === null + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: failureCode } args.finishRunning(verdict) args.report(verdict) }) diff --git a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx index 7e6092839c01..6f6a29ac67ea 100644 --- a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx +++ b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx @@ -6,8 +6,8 @@ import type { AgentLaunchPaneOutcome } from '../../../../shared/agent-launch-pan import { agentLaunchPaneNoticeText } from './agent-launch-pane-notice-text' /** - * What a pane an agent launch laid out shows when its launch failed before its agent ran. Not - * dismissable: it is the pane's state for this session, not a passing error. + * What a pane an agent launch laid out shows when its agent is not running in it. Not dismissable: + * it is the pane's state for this session, not a passing error. */ export function AgentLaunchPaneNotice({ refusal, diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts index f01a9528044d..e624a2328d50 100644 --- a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts @@ -35,6 +35,12 @@ function notStartedReason(code: string): string | null { } export function agentLaunchPaneNoticeText(refusal: AgentLaunchPaneOutcome): string { + if (refusal.kind === 'unconfirmed') { + return translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed', + "Couldn't confirm the agent started." + ) + } const lead = translate( 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted', "The agent couldn't start." diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts index 6c2d421b577f..ab4274e37644 100644 --- a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' const CATALOG: Record = { 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted': '智能体无法启动。', + 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed': '无法确认智能体已启动。', 'auto.components.terminal.pane.AgentLaunchPaneNotice.exitedDuringStart': '它在启动时退出了。' } vi.mock('@/i18n/i18n', () => ({ @@ -23,6 +24,10 @@ describe("the pane of a launch whose agent isn't running", () => { agentLaunchPaneNoticeText({ kind: 'not-started', code: 'agent_launch_pane_already_live' }) ).toBe('智能体无法启动。') }) + + it("says it can't confirm, never that it failed, when nobody knows", () => { + expect(agentLaunchPaneNoticeText({ kind: 'unconfirmed' })).toBe('无法确认智能体已启动。') + }) }) describe('which pane shows it', () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44290c4e484b..47918f63536f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3578,6 +3578,7 @@ "copyPrompt": "Copy prompt", "exitedDuringStart": "It exited while starting.", "notStarted": "The agent couldn't start.", + "unconfirmed": "Couldn't confirm the agent started.", "workspaceGone": "Its workspace is no longer available." }, "TerminalSleepingPaneOverlay": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 24a0aaa1fc8f..ff33ef7f4d47 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3347,6 +3347,7 @@ "copyPrompt": "Copiar prompt", "exitedDuringStart": "Se cerró mientras se iniciaba.", "notStarted": "El agente no pudo iniciarse.", + "unconfirmed": "No se pudo confirmar que el agente se iniciara.", "workspaceGone": "Su espacio de trabajo ya no está disponible." } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0c9e5ada7cc4..2b3bf9eca364 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3452,6 +3452,7 @@ "copyPrompt": "Copier le prompt", "exitedDuringStart": "Il s'est arrêté pendant le démarrage.", "notStarted": "L'agent n'a pas pu démarrer.", + "unconfirmed": "Impossible de confirmer que l'agent a démarré.", "workspaceGone": "Son espace de travail n'est plus disponible." } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 54ba4227d018..47874e9d9f26 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3347,6 +3347,7 @@ "copyPrompt": "プロンプトをコピー", "exitedDuringStart": "起動中に終了しました。", "notStarted": "Agent を起動できませんでした。", + "unconfirmed": "Agent が起動したか確認できませんでした。", "workspaceGone": "ワークスペースは利用できなくなりました。" } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e83f1f526431..d6688c78ee1f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3347,6 +3347,7 @@ "copyPrompt": "프롬프트 복사", "exitedDuringStart": "시작하는 중에 종료되었습니다.", "notStarted": "에이전트를 시작하지 못했습니다.", + "unconfirmed": "에이전트가 시작되었는지 확인하지 못했습니다.", "workspaceGone": "작업 공간을 더 이상 사용할 수 없습니다." } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6067dafd75ce..e33c7300ca05 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3347,6 +3347,7 @@ "copyPrompt": "复制提示", "exitedDuringStart": "它在启动时退出了。", "notStarted": "智能体无法启动。", + "unconfirmed": "无法确认智能体已启动。", "workspaceGone": "其工作区已不可用。" } } 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 6b32b36a3613..c05578a2f381 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 @@ -86,6 +86,14 @@ describe("a launch pane's verdict in the window", () => { expect(isAgentLaunchPaneOver(WT, tabId, 'another-leaf')).toBe(false) }) + it('unconfirmed: the notice stays, and the pane still asks main, so a late agent attaches', () => { + apply({ kind: 'unconfirmed' }) + expect(launchTab()?.agentLaunchPane?.outcome).toEqual({ kind: 'unconfirmed' }) + expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(false) + apply({ kind: 'proceed' }) + expect(launchTab()?.agentLaunchPane).toBeUndefined() + }) + it('final, for a launch pane the user already closed out of a split: nothing is kept', () => { store.getState().setTabLayout(tabId, { root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' }, 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 b728cf2352eb..db29b4534211 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -9,7 +9,8 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layou import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' -/** The tab keeps a final outcome for this pane: its notice stays, and the pane never spawns. */ +/** The pane's launch failed before its agent ran: its notice stays, and the pane never spawns. An + * unconfirmed one still asks main, which attaches the agent if it turned up. */ export function isAgentLaunchPaneOver( worktreeId: string | undefined, tabId: string | undefined, @@ -19,7 +20,7 @@ export function isAgentLaunchPaneOver( ? useAppStore.getState().tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) : undefined const pane = tab?.agentLaunchPane - return pane !== undefined && pane.leafId === leafId && pane.outcome !== undefined + return pane !== undefined && pane.leafId === leafId && pane.outcome?.kind === 'not-started' } export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { @@ -36,7 +37,8 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): forgetAgentLaunchPanePrompt(tabId) state.setTabAgentLaunchPane(tabId, undefined) return - case 'not-started': { + case 'not-started': + case 'unconfirmed': { const root = state.terminalLayoutsByTabId[tabId]?.root if (root && !collectLeafIdsInOrder(root).includes(leafId)) { // The user closed that pane: nothing is left for the outcome to describe. 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 60ea51121236..abccb529cc46 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -167,6 +167,7 @@ describe('a desktop launch through the host', () => { await expect(outcome).resolves.toEqual({ kind: 'not-started', + unconfirmed: false, code: 'agent_session_operation_conflict' }) expect(launchTab(tabId)).toBeUndefined() @@ -193,6 +194,7 @@ describe('a desktop launch through the host', () => { await expect(outcome).resolves.toEqual({ kind: 'not-started', + unconfirmed: false, code: 'worktree_not_found' }) expect(launchTab(tabId)).toBeUndefined() diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 1d828450cce1..3ce247f95559 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -53,12 +53,12 @@ export type HostAgentLaunchArgs = { export type HostAgentLaunchOutcome = /** The agent was started in this tab's pane, which is attached to it. */ | { kind: 'started' } - /** The pane shows how the launch ended. */ + /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ | { kind: 'pane-says' } /** The user closed the tab while it started, and with it the launch: nothing more to say. */ | { kind: 'closed-by-user' } - /** The tab is gone, so the window says nothing started there. */ - | { kind: 'not-started'; code?: string } + /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ + | { kind: 'not-started'; unconfirmed: boolean; code?: string } /** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ const ADMISSION_REFUSAL_CODES = new Set([ @@ -125,7 +125,11 @@ async function settleLaunch( const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) if (!hostTookPane || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { closeLaunchTab(args.worktreeId, pane.tabId) - return { kind: 'not-started', ...(code ? { code } : {}) } + return { + kind: 'not-started', + unconfirmed: code === 'agent_session_operation_unknown', + ...(code ? { code } : {}) + } } return { kind: 'pane-says' } } finally { diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts index ec552714f706..54213717101b 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -73,7 +73,7 @@ describe('an AI button launched through the host', () => { }) it('never pastes for a launch refused before the agent ran, and says so once', async () => { - deferredOutcome()({ kind: 'not-started', code: 'worktree_not_found' }) + deferredOutcome()({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' }) await expect(launch().promptDeliveryResult).resolves.toEqual({ delivered: false, diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts index 5f85e1979067..47a7f2675e2f 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -29,15 +29,22 @@ function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: str if (outcome.kind !== 'not-started') { return } - toast.error(agentLaunchPaneNoticeText({ kind: 'not-started', code: outcome.code ?? '' }), { - action: { - label: translate( - 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', - 'Copy prompt' - ), - onClick: () => void window.api.ui.writeClipboardText(prompt) + toast.error( + agentLaunchPaneNoticeText( + outcome.unconfirmed + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: outcome.code ?? '' } + ), + { + action: { + label: translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', + 'Copy prompt' + ), + onClick: () => void window.api.ui.writeClipboardText(prompt) + } } - }) + ) } /** diff --git a/src/shared/agent-launch-pane-verdict.ts b/src/shared/agent-launch-pane-verdict.ts index 7da0824ff33a..b36ece9c65d5 100644 --- a/src/shared/agent-launch-pane-verdict.ts +++ b/src/shared/agent-launch-pane-verdict.ts @@ -4,9 +4,12 @@ * only fails with `AGENT_LAUNCH_PANE_REFUSED_CODE`, so nothing is parsed out of error text. */ -/** Why a launch pane shows no agent: the launch failed before its agent ran; `code` is the reason, - * for the window to word. Kept on the tab for this session only. */ -export type AgentLaunchPaneOutcome = { kind: 'not-started'; code: string } +/** Why a launch pane shows no agent, kept on the tab for this session only. */ +export type AgentLaunchPaneOutcome = + /** The launch failed before its agent ran; `code` is the reason, for the window to word. */ + | { kind: 'not-started'; code: string } + /** The launch may have started its agent and nothing can tell. */ + | { kind: 'unconfirmed' } export type AgentLaunchPaneVerdict = /** No launch owns the pane, or its agent runs or ran there: the pane's own spawn decides. */ From 111f40e173c05ad812eb4d27c24973caca260c2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:09:42 -0700 Subject: [PATCH 7/8] fix(agent-launch): main keeps a failed or unknown launch's answer for its pane until it quits, so the pane never spawns a shell under its notice; the window's spawn gate goes --- .../agent-launch-pane-attachment.test.ts | 21 ++++++- .../agent-launch-pane-attachment.ts | 56 +++++++++++-------- .../ipc/pty-agent-launch-pane-attach.test.ts | 3 +- .../methods/agent-launch-instant-tab.test.ts | 5 ++ src/main/runtime/rpc/methods/agent-launch.ts | 2 +- .../terminal-pane/ipc-pty-connect.ts | 13 ----- .../pty-transport-connect-spawn.test.ts | 15 ----- .../src/lib/agent-launch-pane-spawn-hold.ts | 4 +- ...nt-launch-pane-verdict-application.test.ts | 14 +---- .../agent-launch-pane-verdict-application.ts | 14 ----- src/shared/terminal-tab-types.ts | 6 +- 11 files changed, 69 insertions(+), 84 deletions(-) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.test.ts b/src/main/agent-launch/agent-launch-pane-attachment.test.ts index 745b71d9191a..4f34b1360dc7 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.test.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.test.ts @@ -29,7 +29,26 @@ describe('a pane an agent launch laid out', () => { kind: 'not-started', code: 'agent_session_exited_during_start' }) - // Over: the next spawn of this pane is an ordinary one. + // Over, for this process: a later spawn of the pane gets the same answer, never a shell. + await expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).resolves.toEqual({ + kind: 'not-started', + code: 'agent_session_exited_during_start' + }) + // After a restart nothing is left: the pane is an ordinary one. + resetAgentLaunchPanesForTests() + expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull() + }) + + it('forgets a failed launch once the user closes its pane, or a process holds it', async () => { + const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:3f2504e0-4f89-41d3-9a0c-0305e82c3301' } + trackRunningAgentLaunchPane(pane).finish({ kind: 'not-started', code: 'boom' }) + markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId: 'tab-1' }) + expect(resolveAgentLaunchPaneVerdict(pane, notLive)).toBeNull() + + trackRunningAgentLaunchPane(PANE).finish({ kind: 'unconfirmed' }) + await expect(resolveAgentLaunchPaneVerdict(PANE, () => true)).resolves.toEqual({ + kind: 'proceed' + }) expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull() }) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 151644c2d197..7cba089c0d27 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -1,13 +1,12 @@ /** * What a pane may do when an `agent.launch` showed it before its agent existed. * - * Derived on every spawn, from this process alone: a launch still running here for the pane says - * how it ended, and whether a process holds the pane says the rest. A launch whose outcome is - * unknown leaves the pane waiting for its agent until this process exits. Otherwise, with no launch - * running for the pane (after a restart, or once it ended) the pane is an ordinary pane: its spawn - * reattaches a live process, starts a fresh shell for an exited or absent one, or shows the terminal - * host's own unavailable state, exactly as any tab does. The launch record is never read for it. A user's close - * of its tab or pane, committed to main, stops a running launch. + * Derived on every spawn, from this process alone: a launch running here for the pane, or the + * failure one left it, says how it ended, and whether a process holds the pane says the rest. With + * neither (after a restart) the pane is an ordinary pane: its spawn reattaches a live process, + * starts a fresh shell for an exited or absent one, or shows the terminal host's own unavailable + * state, exactly as any tab does. The launch record is never read for it. A user's close of its tab + * or pane, committed to main, stops a running launch and forgets a failed one. */ import type { AgentLaunchPaneVerdict } from '../../shared/agent-launch-pane-verdict' @@ -30,8 +29,11 @@ type RunningLaunch = { } const runningLaunchesByPane = new Map() -/** Panes whose launch may have started its agent: each attaches if it turns up, else says so. */ -const unconfirmedPanes = new Set() +/** What a launch that failed, or whose outcome is unknown, left its pane, until a process holds it. */ +const failedLaunchesByPane = new Map< + string, + { pane: AgentLaunchPane; verdict: AgentLaunchPaneVerdict } +>() function paneKeyOf(pane: AgentLaunchPane): string { return JSON.stringify([pane.worktreeId, pane.paneKey]) @@ -63,7 +65,7 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent closedByUser: false } runningLaunchesByPane.set(key, running) - unconfirmedPanes.delete(key) + failedLaunchesByPane.delete(key) let finished = false return { agentBound: () => settle(PROCEED), @@ -74,8 +76,8 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent finished = true if (runningLaunchesByPane.get(key) === running) { runningLaunchesByPane.delete(key) - if (verdict.kind === 'unconfirmed') { - unconfirmedPanes.add(key) + if (verdict.kind === 'not-started' || verdict.kind === 'unconfirmed') { + failedLaunchesByPane.set(key, { pane, verdict }) } } settle(verdict) @@ -85,16 +87,24 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent } } -function runningLaunchesIn( +function launchesIn( + launches: Map, worktreeId: string, target: TerminalSurfaceCloseTarget -): RunningLaunch[] { - return [...runningLaunchesByPane.values()].filter((running) => { - const pane = running.pane.worktreeId === worktreeId ? parsePaneKey(running.pane.paneKey) : null +): T[] { + return [...launches.values()].filter((launch) => { + const pane = launch.pane.worktreeId === worktreeId ? parsePaneKey(launch.pane.paneKey) : null return pane?.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId) }) } +function runningLaunchesIn( + worktreeId: string, + target: TerminalSurfaceCloseTarget +): RunningLaunch[] { + return launchesIn(runningLaunchesByPane, worktreeId, target) +} + /** The user closed a tab, or one pane of it: every launch still running there stops. */ export function markAgentLaunchesClosedByUser( worktreeId: string, @@ -103,6 +113,9 @@ export function markAgentLaunchesClosedByUser( for (const running of runningLaunchesIn(worktreeId, target)) { running.closedByUser = true } + for (const failed of launchesIn(failedLaunchesByPane, worktreeId, target)) { + failedLaunchesByPane.delete(paneKeyOf(failed.pane)) + } } /** A launch still owns this pane: only its own spawn may put a process there. */ @@ -120,9 +133,7 @@ async function settleVerdict( pane: AgentLaunchPane, isPaneLive: IsAgentLaunchPaneLive ): Promise { - let verdict: AgentLaunchPaneVerdict = unconfirmedPanes.has(paneKeyOf(pane)) - ? { kind: 'unconfirmed' } - : PROCEED + let verdict = failedLaunchesByPane.get(paneKeyOf(pane))?.verdict ?? PROCEED // A retry can start another launch into the same pane once the first is over. for ( let running = runningLaunchesByPane.get(paneKeyOf(pane)); @@ -143,7 +154,8 @@ async function settleVerdict( if (!isPaneLive(pane.paneKey)) { return verdict } - unconfirmedPanes.delete(paneKeyOf(pane)) + // A process holds the pane now: it is an ordinary pane again. + failedLaunchesByPane.delete(paneKeyOf(pane)) return PROCEED } @@ -156,12 +168,12 @@ export function resolveAgentLaunchPaneVerdict( isPaneLive: IsAgentLaunchPaneLive ): Promise | null { const key = paneKeyOf(pane) - return runningLaunchesByPane.has(key) || unconfirmedPanes.has(key) + return runningLaunchesByPane.has(key) || failedLaunchesByPane.has(key) ? settleVerdict(pane, isPaneLive) : null } export function resetAgentLaunchPanesForTests(): void { runningLaunchesByPane.clear() - unconfirmedPanes.clear() + failedLaunchesByPane.clear() } diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index d399b29d131b..c1755edd2e91 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -212,7 +212,8 @@ const DAEMON_ANSWERS = [ ['daemon gone: the terminal host is unavailable, as for any tab', 'gone'] ] as const -describe('a launch pane after a restart', () => { +// What a launch pane is after a restart: nothing about its launch is restored, so it spawns as any pane. +describe('a restored pane after a restart', () => { const { handlers, mainWindow } = setupPtyIpcSuite() it.each(DAEMON_ANSWERS)('%s', async (_name, daemon) => { 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 a09e9a0050ad..4e5382f80cb2 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 @@ -369,6 +369,11 @@ describe('the pane after its launch', () => { { kind: 'not-started', code: 'spawn claude ENOENT' } ) ) + // A spawn of the pane after the verdict, in this process: refused the same way, no shell. + await expect(paneVerdict(runtime)).resolves.toEqual({ + kind: 'not-started', + code: 'spawn claude ENOENT' + }) // A restarted host: no launch in memory, and the record is not read for the pane. resetAgentLaunchPanesForTests() await expect(paneVerdict(hostWithWindow())).resolves.toBe('unowned') diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index cd07fa6ff297..f99a4951f7f6 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -158,7 +158,7 @@ function runLegacyAgentLaunch( params: AgentLaunchParams, context: RpcContext ): Promise { - // No early tab: without a launch record its pane could not learn how the launch ended. + // No early tab, as before: only a replay-safe launch shows its tab before its agent exists. const execute = async () => runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime, null), context, { early: null, diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 1cf2d9f28af1..121bd210b040 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -16,8 +16,6 @@ import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold' -import { isAgentLaunchPaneOver } from '@/lib/agent-launch-pane-verdict-application' -import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -87,17 +85,6 @@ export async function connectIpcPty( return } } - // A launch pane whose agent couldn't start says so for this session, never over a shell. - if ( - !admittedSessionId && - isAgentLaunchPaneOver( - transportOptions.worktreeId, - transportOptions.tabId, - transportOptions.leafId - ) - ) { - throw new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) - } if (options.shouldContinue && !options.shouldContinue()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index d6a0b0d8c464..ca8d7a816b4a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -1,9 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' -const isAgentLaunchPaneOver = vi.hoisted(() => vi.fn(() => false)) -vi.mock('@/lib/agent-launch-pane-verdict-application', () => ({ isAgentLaunchPaneOver })) - describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null @@ -131,18 +128,6 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) - // The same session: the pane mounts again after its launch failed, and keeps its notice, no shell. - it("never spawns a launch pane whose agent couldn't start", async () => { - const { createIpcPtyTransport } = await import('./pty-transport') - const pane = { worktreeId: 'wt', tabId: 'tab-over', leafId: 'leaf-over' } - isAgentLaunchPaneOver.mockReturnValueOnce(true) - const onError = vi.fn() - await createIpcPtyTransport(pane).connect({ url: '', callbacks: { onError } }) - - expect(isAgentLaunchPaneOver).toHaveBeenCalledWith('wt', 'tab-over', 'leaf-over') - expect(window.api.pty.spawn).not.toHaveBeenCalled() - }) - it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts index 4278d413b7e0..6c22f7278b01 100644 --- a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts @@ -4,8 +4,8 @@ * Such a pane must not spawn until the host has taken it: before that, main knows of no launch for * it and would give it a shell, which the host's own spawn would then be refused over. The host * takes the pane when it asks this window to show the launch's tab, so the hold ends there, or when - * the launch is over. Session-only: after a reload the pane's spawn reads the launch record like any - * launch pane. + * the launch is over. Session-only: after a reload the pane's spawn asks main, which waits for a launch + * still running there. */ type Hold = { released: Promise; release: () => void } 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 c05578a2f381..e49cb2edf042 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 @@ -30,8 +30,7 @@ vi.mock('@/components/terminal-pane/closed-terminal-leaf-notice', () => ({ createTabsSliceMockApi() -const { applyAgentLaunchPaneVerdict, isAgentLaunchPaneOver } = - await import('./agent-launch-pane-verdict-application') +const { applyAgentLaunchPaneVerdict } = await import('./agent-launch-pane-verdict-application') const { agentLaunchPanePrompt, rememberAgentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') const { wasAgentLaunchPaneClosedByUser } = await import('./agent-launch-pane-closes') @@ -78,18 +77,9 @@ describe("a launch pane's verdict in the window", () => { expect(agentLaunchPanePrompt(tabId)).toBe('fix the build') }) - // As on main: a pane that mounts again this session keeps the notice and spawns no shell under it. - it('final: the pane is over for this session; pending or settled, it is not', () => { - expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(false) - apply({ kind: 'not-started', code: 'boom' }) - expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(true) - expect(isAgentLaunchPaneOver(WT, tabId, 'another-leaf')).toBe(false) - }) - - it('unconfirmed: the notice stays, and the pane still asks main, so a late agent attaches', () => { + it('unconfirmed: the notice stays until the agent turns up after all', () => { apply({ kind: 'unconfirmed' }) expect(launchTab()?.agentLaunchPane?.outcome).toEqual({ kind: 'unconfirmed' }) - expect(isAgentLaunchPaneOver(WT, tabId, LEAF)).toBe(false) apply({ kind: 'proceed' }) expect(launchTab()?.agentLaunchPane).toBeUndefined() }) 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 db29b4534211..4cff8e1df090 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -9,20 +9,6 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layou import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' -/** The pane's launch failed before its agent ran: its notice stays, and the pane never spawns. An - * unconfirmed one still asks main, which attaches the agent if it turned up. */ -export function isAgentLaunchPaneOver( - worktreeId: string | undefined, - tabId: string | undefined, - leafId: string | undefined -): boolean { - const tab = worktreeId - ? useAppStore.getState().tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) - : undefined - const pane = tab?.agentLaunchPane - return pane !== undefined && pane.leafId === leafId && pane.outcome?.kind === 'not-started' -} - export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { const { worktreeId, tabId, leafId, verdict } = event const state = useAppStore.getState() diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 4ddb1842ef00..5b405ba99875 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -96,9 +96,9 @@ export type TerminalTab = { launchAgent?: TuiAgent /** * The pane an `agent.launch` laid out before its agent existed, for this session only. Cleared once - * the agent attaches or the pane is an ordinary terminal again. `outcome`: the launch never ran - * there, and the pane says so until a different launch (`operationId`) reuses it; a retry of the - * same launch changes nothing. + * the agent attaches or the pane is an ordinary terminal again. `outcome`: what main says the + * launch left the pane, which it keeps until it quits; the pane says so until a different launch + * (`operationId`) reuses it, and a retry of the same launch changes nothing. Never saved. */ agentLaunchPane?: { leafId: string; operationId?: string; outcome?: AgentLaunchPaneOutcome } /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a From 0c745d9cc704d70f4eaf06ed1fb2805d17e4b155 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:58:13 -0700 Subject: [PATCH 8/8] fix(agent-launch): a failure kept for a pane goes when the pane is closed during its launch, moved to another tab, or its workspace is removed; comments say what an unknown outcome and a reload now do --- .../agent-launch-pane-attachment.test.ts | 23 ++++++++++++++++++ .../agent-launch-pane-attachment.ts | 24 ++++++++++++++++--- src/main/ipc/pty/ipc/leaf-move.ts | 6 +++++ ...runtime-resolve-worktree-removal-target.ts | 2 ++ .../methods/agent-launch-execution-outcome.ts | 2 +- src/renderer/src/lib/workspace-session.ts | 2 +- 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.test.ts b/src/main/agent-launch/agent-launch-pane-attachment.test.ts index 4f34b1360dc7..5a2f91bde467 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.test.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { + forgetFailedAgentLaunches, markAgentLaunchesClosedByUser, resetAgentLaunchPanesForTests, resolveAgentLaunchPaneVerdict, @@ -52,6 +53,28 @@ describe('a pane an agent launch laid out', () => { expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull() }) + it('keeps no failure for a pane the user closed before its launch ended', () => { + const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:3f2504e0-4f89-41d3-9a0c-0305e82c3301' } + const running = trackRunningAgentLaunchPane(pane) + markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId: 'tab-1' }) + running.finish({ kind: 'not-started', code: 'agent_launch_tab_closed' }) + expect(resolveAgentLaunchPaneVerdict(pane, notLive)).toBeNull() + }) + + it('forgets a failure once its pane moves to another tab or its workspace goes', () => { + const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + const moved = { worktreeId: 'wt-1', paneKey: `tab-1:${leafId}` } + const removed = { worktreeId: 'wt-2', paneKey: `tab-2:${leafId}` } + trackRunningAgentLaunchPane(moved).finish({ kind: 'unconfirmed' }) + trackRunningAgentLaunchPane(removed).finish({ kind: 'not-started', code: 'boom' }) + + forgetFailedAgentLaunches('wt-1', { kind: 'pane', tabId: 'tab-1', leafId }) + forgetFailedAgentLaunches('wt-2', null) + + expect(resolveAgentLaunchPaneVerdict(moved, notLive)).toBeNull() + expect(resolveAgentLaunchPaneVerdict(removed, notLive)).toBeNull() + }) + it("hears the user's close only while the pane's launch is running", () => { const tabId = 'tab-closing' const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 7cba089c0d27..18c8525a886a 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -76,7 +76,11 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent finished = true if (runningLaunchesByPane.get(key) === running) { runningLaunchesByPane.delete(key) - if (verdict.kind === 'not-started' || verdict.kind === 'unconfirmed') { + // A pane the user closed meanwhile is gone: nothing is left for its failure to answer. + if ( + !running.closedByUser && + (verdict.kind === 'not-started' || verdict.kind === 'unconfirmed') + ) { failedLaunchesByPane.set(key, { pane, verdict }) } } @@ -87,14 +91,19 @@ export function trackRunningAgentLaunchPane(pane: AgentLaunchPane): RunningAgent } } +/** `null` targets every pane of the workspace. */ function launchesIn( launches: Map, worktreeId: string, - target: TerminalSurfaceCloseTarget + target: TerminalSurfaceCloseTarget | null ): T[] { return [...launches.values()].filter((launch) => { const pane = launch.pane.worktreeId === worktreeId ? parsePaneKey(launch.pane.paneKey) : null - return pane?.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId) + return ( + pane !== null && + (target === null || + (pane.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId))) + ) }) } @@ -113,6 +122,15 @@ export function markAgentLaunchesClosedByUser( for (const running of runningLaunchesIn(worktreeId, target)) { running.closedByUser = true } + forgetFailedAgentLaunches(worktreeId, target) +} + +/** The pane left its address (closed, moved to another tab, or its workspace removed): a failure + * kept for it has no pane left to answer. `null` forgets the whole workspace's. */ +export function forgetFailedAgentLaunches( + worktreeId: string, + target: TerminalSurfaceCloseTarget | null +): void { for (const failed of launchesIn(failedLaunchesByPane, worktreeId, target)) { failedLaunchesByPane.delete(paneKeyOf(failed.pane)) } diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts index c8b0cb9f07a7..a8dcc8969739 100644 --- a/src/main/ipc/pty/ipc/leaf-move.ts +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -2,6 +2,7 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { agentHookServer } from '../../../agent-hooks/server' +import { forgetFailedAgentLaunches } from '../../../agent-launch/agent-launch-pane-attachment' import { isTerminalLeafMoveRequest, terminalLeafMovePaneKeys, @@ -22,6 +23,11 @@ export async function commitLeafMoveAndRekey( return result } const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + forgetFailedAgentLaunches(request.worktreeId, { + kind: 'pane', + tabId: request.sourceTabId, + leafId: request.leafId + }) try { // The process keeps the pane key baked into its env, so status must alias old to new. agentHookServer.transferPaneAuthority( diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index ed4aa8981b48..f861b0e91f7d 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -12,6 +12,7 @@ import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../worktree-removal-repo-owner' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' +import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment' import { closeClientHostedBrowserPagesForWorktree } from './worktree-browser-client-page-close' import type { ForceDeleteWorktreeBranchResult, @@ -174,6 +175,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith this.cancelScheduledMobileSessionTabsChanged(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) + forgetFailedAgentLaunches(worktreeId, null) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) closeClientHostedBrowserPagesForWorktree(this, worktreeId) diff --git a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts index b35575572f09..6531210ddccf 100644 --- a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts +++ b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts @@ -35,7 +35,7 @@ export function settleQuietly(settlement: Promise): Promise { } /** Runs a launch whose tab may already be showing, and tells its pane how the launch ended: why, - * when it definitely failed; nothing when its outcome is unknown, so the pane is an ordinary one. */ + * when it definitely failed; that it couldn't confirm the start when its outcome is unknown. */ export async function withEarlyTab( early: EarlyAgentLaunchTab | null, run: () => Promise diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index a27af150d725..c519fb05de65 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -205,7 +205,7 @@ export function buildSanitizedTabsByWorktree( ): WorkspaceSessionState['tabsByWorktree'] { // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. // Same for the recovery ledger: it describes a mounted pane's in-flight heal, so a persisted one would refuse the first recovery after restart. - // A launch pane's outcome is this session's notice, so a reload spawns it like a restart does; a pending launch stays, for its verdict. + // A launch pane's outcome is not saved: a reload asks main again, which keeps it until it quits; after a restart the pane spawns like any other. return Object.fromEntries( Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId,