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..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,98 +1,78 @@ -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 { + forgetFailedAgentLaunches, 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, 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() + }) + + 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", () => { @@ -115,113 +95,10 @@ 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 () => { + it('attaches to a process that holds the pane, whatever the launch says', async () => { const running = trackRunningAgentLaunchPane(PANE) - const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence([])) - running.finish({ tabTakenBack: true }) - 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' }) + 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..18c8525a886a 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -1,58 +1,59 @@ /** * 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 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 { - 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() +/** 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: 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) => { @@ -64,32 +65,53 @@ export function trackRunningAgentLaunchPane( closedByUser: false } runningLaunchesByPane.set(key, running) + failedLaunchesByPane.delete(key) let finished = false return { - agentBound: () => settle({ tabTakenBack: false }), - finish: (outcome) => { + agentBound: () => settle(PROCEED), + finish: (verdict) => { if (finished) { return } finished = true if (runningLaunchesByPane.get(key) === running) { runningLaunchesByPane.delete(key) + // 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 }) + } } - settle(outcome) - resolve(outcome) + settle(verdict) + resolve(verdict) }, closedByUser: () => running.closedByUser } } +/** `null` targets every pane of the workspace. */ +function launchesIn( + launches: Map, + worktreeId: string, + target: TerminalSurfaceCloseTarget | null +): T[] { + return [...launches.values()].filter((launch) => { + const pane = launch.pane.worktreeId === worktreeId ? parsePaneKey(launch.pane.paneKey) : null + return ( + pane !== null && + (target === null || + (pane.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId))) + ) + }) +} + function runningLaunchesIn( worktreeId: string, target: TerminalSurfaceCloseTarget ): RunningLaunch[] { - return [...runningLaunchesByPane.values()].filter((running) => { - const pane = running.pane.worktreeId === worktreeId ? parsePaneKey(running.pane.paneKey) : null - return pane?.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId) - }) + return launchesIn(runningLaunchesByPane, worktreeId, target) } /** The user closed a tab, or one pane of it: every launch still running there stops. */ @@ -100,6 +122,18 @@ 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)) + } } /** A launch still owns this pane: only its own spawn may put a process there. */ @@ -110,107 +144,54 @@ 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 = 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)); + 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 + if (!isPaneLive(pane.paneKey)) { + return verdict } - // 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' } + // A process holds the pane now: it is an ordinary pane again. + failedLaunchesByPane.delete(paneKeyOf(pane)) + return PROCEED } /** - * 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) + const key = paneKeyOf(pane) + return runningLaunchesByPane.has(key) || failedLaunchesByPane.has(key) + ? settleVerdict(pane, isPaneLive) + : null } export function resetAgentLaunchPanesForTests(): void { runningLaunchesByPane.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 77cc18fd8607..c1755edd2e91 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,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +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' @@ -7,15 +9,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 +94,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 +108,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 +120,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 +134,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 +146,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 +155,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 +170,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 +186,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() @@ -338,3 +204,104 @@ 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 + +// 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) => { + 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)) + 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 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/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/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-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/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..6531210ddccf 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,21 @@ 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; that it couldn't confirm the start when its outcome is unknown. */ 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 : agentLaunchFailureCode(error) + ) + throw error } } @@ -62,5 +73,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..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 @@ -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,12 @@ 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', 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' }) + await expect(paneVerdict(runtime)).resolves.toBe('unowned') expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() }) @@ -373,17 +353,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,12 +369,14 @@ 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. - resetAgentLaunchPanesForTests() - const reopened = await openTestAgentSessionRecordStore(directory) - await expect(paneVerdict(hostWithWindow(), reopened)).resolves.toMatchObject({ - kind: 'not-started' + // 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') }) it('says it cannot confirm a spawn whose outcome is unknown, and attaches if the agent turns up', async () => { @@ -412,6 +390,20 @@ describe('the pane after its launch', () => { 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 () => { @@ -430,7 +422,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 +435,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 +508,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 +525,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..935f59cf696b 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,19 @@ 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: 'proceed' } + : failureCode === null + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: failureCode } + 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..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, @@ -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..6f6a29ac67ea 100644 --- a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx +++ b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx @@ -7,7 +7,7 @@ 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. + * 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.test.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts index 55b9eef36d5e..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 @@ -31,10 +31,11 @@ describe("the pane of a launch whose agent isn't running", () => { }) 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/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 5e65a5b0a00f..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 @@ -61,22 +61,29 @@ 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') }) + it('unconfirmed: the notice stays until the agent turns up after all', () => { + apply({ kind: 'unconfirmed' }) + expect(launchTab()?.agentLaunchPane?.outcome).toEqual({ kind: 'unconfirmed' }) + 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' }, @@ -122,7 +129,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..4cff8e1df090 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -32,7 +32,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/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) + }) +}) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 6fb3509d1387..c519fb05de65 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 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, @@ -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 5221e0cf8c1d..b36ece9c65d5 100644 --- a/src/shared/agent-launch-pane-verdict.ts +++ b/src/shared/agent-launch-pane-verdict.ts @@ -4,11 +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. */ +/** 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 recorded reason, for the window to word. */ + /** 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' } @@ -30,22 +28,11 @@ 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') - ) +/** 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/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..5b405ba99875 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`: 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 diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index b627b8cddedb..becc1f773da9 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: 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', 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 ──────────────────────────────────────────────