From f3d8cd462c19b5bd58ad1c1baabcf809d60bec40 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 06:14:38 -0700 Subject: [PATCH] fix(orchestration): retire a structured worker's chat tab when the worker settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a structured worker always publishes a real `agent-session:` tab, but every settlement path only called `setSessionTabVisibility(sessionId, false)` plus `host.close(sessionId)`. That clears the DURABLE restore index and leaves the LIVE snapshot untouched, so stop, release and the half-started discard all left a dead "Claude Chat" / "Codex Chat" tab in the worktree's tab bar for the rest of the app session — five dispatches, five dead tabs — and opening one re-attached the released session, respawning a provider child outside orchestration's hold accounting. The snapshot-pruning half of `closeStructuredAgentSessionTab` is extracted into `structured-agent-session-tab-retirement.ts` and exposed on the runtime as `retireStructuredAgentSessionTabFromSnapshot`, so the user-initiated tab close and the three settlements share one implementation instead of a second copy. The settlement side is best-effort BY CONSTRUCTION: it runs only after the close is already proven, calls the runtime method optionally, and swallows any throw. It talks to no renderer, so the startup release reconciler can call it too. Nothing here can turn a proven stop into `release_unknown`. --- .../orca-runtime-close-mobile-session-tab.ts | 2 +- ...time-close-structured-agent-session-tab.ts | 50 ++-- ...chestration-structured-worker-lifecycle.ts | 16 +- ...orchestration-structured-worker-session.ts | 20 +- .../structured-worker-tab-retirement.test.ts | 253 ++++++++++++++++++ ...structured-agent-session-tab-retirement.ts | 79 ++++++ 6 files changed, 386 insertions(+), 34 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-retirement.ts diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index a0a02474911..42f17c533e3 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -292,7 +292,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } } } - await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) + await this.closeStructuredAgentSessionTab(tab) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index bd282b6575d..83d6b6b98b7 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -13,42 +13,48 @@ import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-pub import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { - protected async closeStructuredAgentSessionTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionAgentTab - ): Promise { + protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise { const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(tab.sessionId, false) } } - const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) - const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => id !== tab.id), - activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, - recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) - })), - tabs: nextTabs - } - this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) // Retire durable visibility and the runtime snapshot before stopping the provider. + this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId) if (typeof host?.close === 'function') { await host.close(tab.sessionId) } } + /** + * Prunes a structured session's chat tab from whichever worktree snapshot still carries it. + * + * Public because orchestration settles structured workers outside the tab surface: stop, release + * and the half-started discard all prove their own close and then have to retire the tab that + * `publishStructuredAgentSessionTab` put on screen. `setSessionTabVisibility(false)` only clears + * the DURABLE restore index, so without this the dead chat tab survives for the rest of the app + * session and re-attaches the released session when opened. + * + * Snapshot-only and renderer-free: it never asks the renderer to close anything, so it is safe on + * the startup release reconciler where no renderer exists. + */ + retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const nextSnapshot = retireStructuredAgentSessionTabFrom(snapshot, sessionId) + if (!nextSnapshot) { + continue + } + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return true + } + return false + } + // Why: a refused echoed close means the echoing client already pruned its // local mirror. Bump the version and emit the unchanged snapshot so clients // that dedupe by snapshotVersion re-add and re-attach the still-live tab. diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index ac7e613f0d0..5ceb74f4a0d 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -34,6 +34,7 @@ import { structuredWorkerAgent, type StructuredWorkerObservation } from '../../structured-worker-authority' +import { retireSettledStructuredWorkerTab } from '../../structured-agent-session-tab-retirement' import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' @@ -62,7 +63,10 @@ export function resolveStructuredWorkerForDispatch( export async function stopStructuredWorker( identity: StructuredWorkerIdentity, dispatchId: string, - runtime?: Pick + runtime?: Pick< + OrcaRuntimeService, + 'forgetStructuredSessionMail' | 'retireStructuredAgentSessionTabFromSnapshot' + > ): Promise<{ stopped: boolean; reason?: string }> { const host = getStructuredAgentSessionHost() if (!host) { @@ -79,9 +83,13 @@ export async function stopStructuredWorker( } releaseStructuredWorkerSession(dispatchId, runtime) const after = observeStructuredWorker(identity) - return after.status === 'live' - ? { stopped: false, reason: 'The structured session is still attached after close.' } - : { stopped: true } + if (after.status === 'live') { + return { stopped: false, reason: 'The structured session is still attached after close.' } + } + // Only past the proof, and structurally unable to throw: the worker's chat tab is retired from + // the live snapshot, which `setSessionTabVisibility(false)` above does not do. + retireSettledStructuredWorkerTab(identity.sessionId, runtime) + return { stopped: true } } /** The structured half of `worker-read`, or null when a PTY worker owns the dispatch. */ diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 2fa91b9959b..9d5fcfeca22 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -23,6 +23,7 @@ import { structuredPointerPayloadFingerprint } from '../../orchestration/structured-pointer-operation-id' import { structuredPointerCallerKey } from '../../orchestration/structured-mailbox-pointer-host' +import { retireSettledStructuredWorkerTab } from '../../structured-agent-session-tab-retirement' import { mintStructuredWorkerHandle, structuredWorkerHostScope, @@ -150,7 +151,7 @@ export async function createStructuredWorkerSession(args: { // that no dispatch owns and that nothing else in the runtime will ever retire. structuredWorkerIdentities.forget(identity.handle) if (structuredCreateMayHaveCommitted(created)) { - await discardCreatedSession(sessionId) + await discardCreatedSession(sessionId, args.runtime) } throw error } @@ -174,13 +175,16 @@ function structuredCreateMayHaveCommitted( /** * Best-effort teardown of a session created by a worker start that then failed. * - * Stops the provider child and drops the DURABLE tab reference, so nothing restores the chat after - * a restart. It does not prune the live tab snapshot — the same shape `agentSession.close` has, - * where the surface that opened the tab is what retires it — so the background tab this start - * published stays on screen for the rest of the session. Both calls are no-ops for a session that - * was never attached, which is why a non-definitive refusal can reach here unconditionally. + * Stops the provider child, drops the DURABLE tab reference so nothing restores the chat after a + * restart, and — only once the close came back without throwing — retires the background tab this + * start published from the live snapshot. All three are no-ops for a session that was never + * attached, which is why a non-definitive refusal can reach here unconditionally. A close that + * threw leaves the tab alone: the child may still be running, and the tab is the way to reach it. */ -async function discardCreatedSession(sessionId: string): Promise { +async function discardCreatedSession( + sessionId: string, + runtime: Pick +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return @@ -194,7 +198,9 @@ async function discardCreatedSession(sessionId: string): Promise { sessionId, error ) + return } + retireSettledStructuredWorkerTab(sessionId, runtime) } /** Delivers the dispatch preamble as the worker's first turn. */ diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts new file mode 100644 index 00000000000..5f679517d8c --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -0,0 +1,253 @@ +/** + * Every structured-worker settlement has to retire the chat tab the worker start published. + * + * `setSessionTabVisibility(false)` only clears the DURABLE restore index. Without the snapshot + * prune, a coordinator that dispatches and releases five structured workers leaves five dead + * "Claude Chat" tabs in the worktree's tab bar, and opening one re-attaches the released session + * outside orchestration's hold and eviction accounting. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import type { WorkerTerminalResourceRow } from '../../orchestration/worker-terminal-ownership' +import { + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation, + type StructuredWorkerIdentity +} from '../../structured-worker-identity' + +const createSpy = vi.fn() +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (...args: unknown[]) => createSpy(...args) +})) + +const { stopStructuredWorker } = await import('./orchestration-structured-worker-lifecycle') +const { createStructuredWorkerSession } = await import('./orchestration-structured-worker-session') +const { completeWorkerTerminalRelease } = await import('./orchestration-worker-release-completion') + +const WORKTREE = 'workspace-1' +const SESSION = 'session-1' +const HANDLE = 'structworker_11111111-1111-4111-a111-111111111111' +const HOST_SCOPE = { kind: 'local', hostId: 'local' } as const + +function installHost(options: { closeThrows?: boolean } = {}) { + let attached = true + const setSessionTabVisibility = vi.fn(async () => {}) + const close = vi.fn(async () => { + if (options.closeThrows) { + throw new Error('close is queued for retry') + } + attached = false + }) + setStructuredAgentSessionHost({ + setSessionTabVisibility, + close, + hasSession: () => attached, + hold: async () => {}, + release: () => {}, + subscribe: () => () => {}, + deps: { + store: { + getRecord: () => ({ + location: { executionHostId: 'local', wslDistro: null }, + lease: { + runtimeKind: 'native', + claimStatus: 'live', + deathEvidence: null, + runtimeFence: 2 + } + }) + } + } + } as never) + return { close, setSessionTabVisibility } +} + +type RuntimeInternals = { + ensureStructuredAgentSessionHost(): Promise + notifyMessageArrived(...args: unknown[]): void + emitMobileSessionTabsSnapshot(snapshot: unknown): void +} + +async function runtimeShowingStructuredTab(): Promise<{ + runtime: OrcaRuntimeService + emit: ReturnType +}> { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as RuntimeInternals + internal.ensureStructuredAgentSessionHost = async () => undefined + internal.notifyMessageArrived = vi.fn() + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WORKTREE, + sessionId: SESSION, + agent: 'claude', + activate: true + }) + const emit = vi.fn() + const original = internal.emitMobileSessionTabsSnapshot.bind(runtime) + internal.emitMobileSessionTabsSnapshot = (snapshot: unknown) => { + emit(snapshot) + original(snapshot) + } + return { runtime, emit } +} + +async function structuredTabIds(runtime: OrcaRuntimeService): Promise { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE}`) + return snapshot.tabs.map((tab) => tab.id) +} + +function registerIdentity(): StructuredWorkerIdentity { + return structuredWorkerIdentities.register({ + handle: HANDLE, + sessionId: SESSION, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(SESSION), + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: WORKTREE, + hostScope: HOST_SCOPE + }) +} + +beforeEach(() => { + structuredWorkerIdentities.clear() + createSpy.mockReset() +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) + structuredWorkerIdentities.clear() + vi.restoreAllMocks() +}) + +describe('structured worker stop retires the chat tab', () => { + it('prunes the tab from the live snapshot and re-emits it', async () => { + installHost() + const identity = registerIdentity() + const { runtime, emit } = await runtimeShowingStructuredTab() + expect(await structuredTabIds(runtime)).toEqual([`agent-session:${SESSION}`]) + + await expect(stopStructuredWorker(identity, 'd1', runtime)).resolves.toEqual({ stopped: true }) + + expect(await structuredTabIds(runtime)).toEqual([]) + const published = await runtime.listMobileSessionTabs(`id:${WORKTREE}`) + expect(published.tabGroups?.[0]?.tabOrder ?? []).toEqual([]) + expect(published.activeTabId).toBeNull() + expect(published.activeTabType).toBeNull() + expect(emit).toHaveBeenCalled() + }) + + it('leaves the tab alone when the close was NOT proven', async () => { + installHost({ closeThrows: true }) + const identity = registerIdentity() + const { runtime } = await runtimeShowingStructuredTab() + + const stop = await stopStructuredWorker(identity, 'd1', runtime) + + expect(stop.stopped).toBe(false) + expect(await structuredTabIds(runtime)).toEqual([`agent-session:${SESSION}`]) + }) + + it('cannot turn a proven stop into a retained one when the prune throws', async () => { + installHost() + const identity = registerIdentity() + const runtime = { + forgetStructuredSessionMail: vi.fn(), + retireStructuredAgentSessionTabFromSnapshot: vi.fn(() => { + throw new Error('snapshot is wedged') + }) + } as unknown as OrcaRuntimeService + + await expect(stopStructuredWorker(identity, 'd1', runtime)).resolves.toEqual({ stopped: true }) + expect(runtime.retireStructuredAgentSessionTabFromSnapshot).toHaveBeenCalledWith(SESSION) + }) + + it('settles a runtime that has no tab surface at all', async () => { + installHost() + const identity = registerIdentity() + await expect(stopStructuredWorker(identity, 'd1')).resolves.toEqual({ stopped: true }) + }) +}) + +describe('structured worker release retires the chat tab', () => { + it('prunes the tab once the release settles', async () => { + installHost() + const identity = registerIdentity() + const { runtime } = await runtimeShowingStructuredTab() + const resource = { + id: 'resource-1', + terminal_handle: HANDLE, + host_scope: JSON.stringify(HOST_SCOPE), + archive_source: 'transcript', + archive_status: 'captured', + ownership_state: 'owned', + release_state: 'requested' + } as WorkerTerminalResourceRow + const db = { + getWorkerDispatch: () => ({ + agent_terminal_handle: HANDLE, + created_at: '2026-09-05 00:00:00' + }), + getDispatchContextById: () => null, + isDispatchProcessCurrent: (args: { paneKey: string; processIncarnation: string }) => + args.paneKey === identity.paneKey && + args.processIncarnation === identity.processIncarnation, + workerTerminalResourceHasIdentityConflict: () => false, + getWorkerTerminalArchive: () => ({ kind: 'transcript_pin' }), + commitWorkerTerminalArchiveForRelease: () => ({ + ...resource, + release_state: 'releasing' + }), + settleWorkerTerminalRelease: () => ({ ...resource, release_state: 'released' }), + markWorkerTerminalReleaseUnknown: (_id: string, error: string) => ({ + ...resource, + release_state: 'unknown', + release_error: error + }) + } as unknown as OrchestrationDb + + await expect( + completeWorkerTerminalRelease({ runtime, db, dispatchId: 'd1', resource }) + ).resolves.toMatchObject({ state: 'released' }) + + expect(await structuredTabIds(runtime)).toEqual([]) + }) +}) + +describe('structured worker discard retires the chat tab', () => { + it('prunes the tab a half-started worker published', async () => { + const { close } = installHost() + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as RuntimeInternals + internal.ensureStructuredAgentSessionHost = async () => undefined + let createdSessionId = '' + createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => { + // The create is what publishes the background tab, and it publishes BEFORE the start can + // fail — which is exactly the tab the discard has to take back. + createdSessionId = args.envelope.sessionId + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WORKTREE, + sessionId: createdSessionId, + agent: 'claude', + activate: false + }) + return { ok: false, refusal: { code: 'agent_session_operation_unknown', message: 'unknown' } } + }) + + await expect( + createStructuredWorkerSession({ + runtime, + worktreeId: WORKTREE, + agent: 'claude', + dispatchId: 'd_discard', + onJournalActivity: () => {} + }) + ).rejects.toThrow(/was refused/) + + expect(close).toHaveBeenCalledWith(createdSessionId) + expect(await structuredTabIds(runtime)).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-retirement.ts b/src/main/runtime/structured-agent-session-tab-retirement.ts new file mode 100644 index 00000000000..aee5ef6d719 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-retirement.ts @@ -0,0 +1,79 @@ +/** + * Removing a structured agent session's chat tab from a live workspace tab snapshot. + * + * Extracted from `closeStructuredAgentSessionTab` so that user-initiated tab closes and + * orchestration settlements (stop / release / discard) retire the same tab the same way, rather + * than orchestration leaving a dead chat tab behind that re-attaches the session when opened. + */ + +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' + +/** The snapshot's tab for a structured session, matched by session id and by published tab id. */ +export function findStructuredAgentSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + sessionId: string +): RuntimeMobileSessionSnapshotTab | null { + const tabId = structuredAgentSessionTabId(sessionId) + return ( + snapshot.tabs.find( + (candidate) => + candidate.type === 'agent-session' && + (candidate.sessionId === sessionId || candidate.id === tabId) + ) ?? null + ) +} + +/** + * The snapshot with that session's tab pruned, or null when it holds no such tab. + * + * Pure: the caller owns storing and emitting, so nothing here can fail a settlement. + */ +export function retireStructuredAgentSessionTabFrom( + snapshot: RuntimeMobileSessionTabsSnapshot, + sessionId: string +): RuntimeMobileSessionTabsSnapshot | null { + const tab = findStructuredAgentSessionTab(snapshot, sessionId) + if (!tab) { + return null + } + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + return { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) + })), + tabs: nextTabs + } +} + +/** + * Retires a settled structured worker's chat tab, and cannot fail the settlement that called it. + * + * Every caller runs this AFTER it has already proven the session's close, so a snapshot problem + * here must never be able to turn a proven stop into `release_unknown`: the runtime method is + * called optionally (a runtime double or an older surface may not have it) and any throw is + * swallowed. It talks to no renderer, so the startup release reconciler can call it too. + */ +export function retireSettledStructuredWorkerTab( + sessionId: string, + runtime: + | { retireStructuredAgentSessionTabFromSnapshot?: (sessionId: string) => boolean } + | undefined +): void { + try { + runtime?.retireStructuredAgentSessionTabFromSnapshot?.(sessionId) + } catch (error) { + console.warn('[orchestration] structured worker tab retirement failed', sessionId, error) + } +}