From 18df97a7bcd3384cbeee71ab98d987c19f846d82 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:41:33 -0700 Subject: [PATCH] Refuse a chat create whose close came first (dark, build switch off by default) (#29659) * Refuse a chat create whose close came first, behind the layout-owner switch With ORCA_LAYOUT_OWNER=main, main remembers a close that reaches it before the chat's create and refuses that create, or closes the chat as it lands. The window then tells this machine's host about a cancel instead of keeping a cancel tombstone; a host it cannot tell still gets the tombstone. Paired hosts keep the tombstones, whose fence module moves to lib/launch-cancel-tombstones-compat/. * Close a chat whose create a cancel beat, on any create outcome The local cancel now retires the chat on main (close and host-tab close), so a create that landed first leaves no tab behind; a tab_not_found answer means the create had not landed. Main closes the chat for any create answer once a close was noted, including a refusal or a throw past commit, and publishes no tab for it. The tombstone fallback notifies listeners. * Keep the switched-off create path byte-identical * Log, not throw, a failed close of a chat closed before create * Run the close-before-create test in the Node SQLite project --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...-agent-session-close-before-create.test.ts | 167 +++++++++++++ ...tured-agent-session-close-before-create.ts | 97 ++++++++ ...structured-agent-session-create-handler.ts | 12 + .../structured-agent-session-create.ts | 31 ++- .../rpc/methods/structured-agent-session.ts | 15 +- ...-agent-session-launch-cancellation.test.ts | 8 +- ...tured-agent-session-launch-cancellation.ts | 8 +- ...tructured-agent-session-launch-registry.ts | 33 ++- ...tured-agent-session-local-launch-cancel.ts | 40 +++ .../inventory-refresh.ts | 2 +- .../snapshot-apply.ts | 2 +- ...host-structured-launch-bookkeeping.test.ts | 2 +- ...uctured-launch-cancel-layout-owner.test.ts | 234 ++++++++++++++++++ 14 files changed, 625 insertions(+), 27 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-close-before-create.ts rename src/renderer/src/lib/{ => launch-cancel-tombstones-compat}/structured-agent-session-launch-cancellation.test.ts (96%) rename src/renderer/src/lib/{ => launch-cancel-tombstones-compat}/structured-agent-session-launch-cancellation.ts (92%) create mode 100644 src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts create mode 100644 src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index a31d0503de3..94b97db94e6 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -253,6 +253,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts new file mode 100644 index 00000000000..bd7b14b4401 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts @@ -0,0 +1,167 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture' +import { + resetStructuredAgentSessionClosesBeforeCreateForTests, + structuredAgentSessionClosedBeforeCreate +} from './structured-agent-session-close-before-create' + +const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true })) +vi.mock('../../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} + +let rig: RestTestRig +const retireTab = vi.fn(() => true) + +beforeEach(async () => { + mocks.layoutOwnerIsMain = true + resetStructuredAgentSessionClosesBeforeCreateForTests() + retireTab.mockClear() + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.restoreAllMocks() +}) + +function createParams() { + return structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '00000000-0000-0000-0000-0000000000fa', + now: NOW + }) +} + +const create = (overrides: Record = {}) => + call('agentSession.create', createParams(), CLIENT, { + retireStructuredAgentSessionTabFromSnapshot: retireTab, + ...overrides + }) +const close = () => call('agentSession.close', { sessionId: SESSION }, CLIENT) + +const refusedAsClosed = { + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'The chat was closed before it was created.' + } + } +} + +describe('a close that reaches main before its create', () => { + it('cancel then create: main refuses the create and makes nothing', async () => { + expect(await close()).toMatchObject({ ok: true }) + + expect(await create()).toMatchObject(refusedAsClosed) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + }) + + it('create then cancel: an ordinary close, and the create still replays', async () => { + expect(await create()).toMatchObject({ ok: true, result: { ok: true } }) + expect(await close()).toMatchObject({ ok: true }) + + expect(await create()).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) + }) + + it('a close while the create resolves its workspace refuses it before anything is written', async () => { + const resolving = Promise.withResolvers() + const entered = Promise.withResolvers() + const creating = create({ + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => { + entered.resolve() + await resolving.promise + return { + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + runtimeKind: 'native' + } + }) + }) + await entered.promise + await close() + resolving.resolve() + + expect(await creating).toMatchObject(refusedAsClosed) + expect(rig.store.getRecord(SESSION)).toBeNull() + }) + + it('a close while the create commits closes the chat as it lands', async () => { + let closing: Promise | undefined + const result = await create({ + publishStructuredAgentSessionTab: vi.fn(async () => { + closing = close() + }) + }) + await closing + + expect(result).toMatchObject(refusedAsClosed) + expect(retireTab).toHaveBeenCalledWith(SESSION) + expect(rig.store.getSessionTabId(SESSION)).toBeNull() + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('a close before a create that fails past commit still closes what it made', async () => { + let closing: Promise | undefined + const result = await create({ + publishStructuredAgentSessionTab: vi.fn(async () => { + // Not awaited: the close's stop queues behind this create. + closing = close() + await vi.waitFor(() => expect(structuredAgentSessionClosedBeforeCreate(SESSION)).toBe(true)) + throw new Error('tab write failed') + }) + }) + + await closing + expect(result).toMatchObject(refusedAsClosed) + expect(rig.store.getRecord(SESSION)).not.toBeNull() + expect(retireTab).toHaveBeenCalledWith(SESSION) + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('switch off: a close before the create is not remembered', async () => { + mocks.layoutOwnerIsMain = false + await close() + mocks.layoutOwnerIsMain = true + + expect(await create()).toMatchObject({ ok: true, result: { ok: true } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.ts new file mode 100644 index 00000000000..7cf750b534e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.ts @@ -0,0 +1,97 @@ +// A chat's close can reach main before its create: the window cancels while its create is still on +// the way. With ORCA_LAYOUT_OWNER=main the window keeps no cancel tombstone for this machine's host, +// so main remembers the close and refuses that create, or closes the chat as its create lands. + +import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire-refusals' +import { refuseUnclassified } from '../../../../shared/agent-session-wire-refusals' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RpcContext } from '../core' + +// Session ids are minted per launch, so an entry only ever matches its own create; the cap bounds +// closes whose create never came. +const MAX_CLOSED_BEFORE_CREATE = 256 +const closedBeforeCreate = new Set() +const createsUnderWay = new Map() + +/** `hasRecord`: a create already committed, so this is an ordinary close unless one is under way. */ +export function noteStructuredAgentSessionClose(sessionId: string, hasRecord: boolean): void { + if (hasRecord && !createsUnderWay.has(sessionId)) { + return + } + closedBeforeCreate.add(sessionId) + for (const oldest of closedBeforeCreate) { + if (closedBeforeCreate.size <= MAX_CLOSED_BEFORE_CREATE) { + break + } + closedBeforeCreate.delete(oldest) + } +} + +export function structuredAgentSessionClosedBeforeCreate(sessionId: string): boolean { + return closedBeforeCreate.has(sessionId) +} + +export function structuredAgentSessionClosedBeforeCreateRefusal(): AgentSessionWireRefusal { + return refuseUnclassified( + 'agent_session_operation_invalid', + 'The chat was closed before it was created.' + ) +} + +/** Wraps `agentSession.create`: a close that arrived while it ran closes the chat it made. */ +export function refusingStructuredAgentSessionCreatesClosedFirst< + Params extends { envelope: { sessionId: string } }, + Result extends { ok: boolean } +>( + create: (params: Params, ctx: RpcContext) => Promise +): ( + params: Params, + ctx: RpcContext +) => Promise { + return async (params, ctx) => { + const { sessionId } = params.envelope + createsUnderWay.set(sessionId, (createsUnderWay.get(sessionId) ?? 0) + 1) + let closedFirst = false + try { + const result = await create(params, ctx) + closedFirst = closedBeforeCreate.has(sessionId) + // The record stays on disk, as for any closed chat; the refusal only says the launch is off. + return closedFirst + ? { ok: false, refusal: structuredAgentSessionClosedBeforeCreateRefusal() } + : result + } catch (error) { + closedFirst = closedBeforeCreate.has(sessionId) + throw error + } finally { + const remaining = (createsUnderWay.get(sessionId) ?? 1) - 1 + if (remaining > 0) { + createsUnderWay.set(sessionId, remaining) + } else { + createsUnderWay.delete(sessionId) + } + // Any answer, a refusal or a throw past commit included, may have left the chat made. + if (closedFirst) { + // Logged, not thrown: the client must still hear that the chat was closed first. + await closeCreatedChat(ctx, sessionId).catch((error: unknown) => + console.warn( + '[structured-agent-session] closing a chat closed before create failed', + error + ) + ) + } + } + } +} + +/** The close the window asked for, run now that the chat exists: the tab first, then the agent. */ +async function closeCreatedChat(ctx: RpcContext, sessionId: string): Promise { + const host = getStructuredAgentSessionHost() + await host?.setSessionTabVisibility?.(sessionId, false) + ctx.runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId) + await host?.close(sessionId, 'user-close') +} + +export function resetStructuredAgentSessionClosesBeforeCreateForTests(): void { + closedBeforeCreate.clear() + createsUnderWay.clear() +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 335648ecd87..c015eb82981 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -18,6 +18,11 @@ import { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + structuredAgentSessionClosedBeforeCreate, + structuredAgentSessionClosedBeforeCreateRefusal +} from './structured-agent-session-close-before-create' /** * The attach-shaped entries take the location from the client instead of resolving it from a @@ -101,6 +106,13 @@ export async function handleStructuredAgentSessionCreate( if ('refusal' in prepared) { return { ok: false, refusal: prepared.refusal } } + // Nothing is created yet, so a close that came first refuses the create outright. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + structuredAgentSessionClosedBeforeCreate(params.envelope.sessionId) + ) { + return { ok: false, refusal: structuredAgentSessionClosedBeforeCreateRefusal() } + } return commitStructuredAgentSessionCreate({ runtime: ctx.runtime, caller: callerFor(ctx), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 8fd8c224519..0f0ca0d752c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -25,6 +25,8 @@ import { resolveUncommittedStructuredCreate, type StructuredCreateRefused } from './structured-agent-session-precommit-refusal' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create' export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost @@ -168,16 +170,20 @@ export async function commitStructuredAgentSessionCreate(args: { : {}), ...(tab ? { + // A chat closed while it was being made gets no tab to show before main closes it. publishTab: () => - args.runtime.publishStructuredAgentSessionTab({ - workspaceId: tab.workspaceId, - sessionId: prepared.attachParams.envelope.sessionId, - agent: tab.agent, - activate: args.activate, - ...(prepared.attachParams.surfaceTabId - ? { tabId: prepared.attachParams.surfaceTabId } - : {}) - }) + ORCA_LAYOUT_OWNER_IS_MAIN && + structuredAgentSessionClosedBeforeCreate(prepared.attachParams.envelope.sessionId) + ? Promise.resolve() + : args.runtime.publishStructuredAgentSessionTab({ + workspaceId: tab.workspaceId, + sessionId: prepared.attachParams.envelope.sessionId, + agent: tab.agent, + activate: args.activate, + ...(prepared.attachParams.surfaceTabId + ? { tabId: prepared.attachParams.surfaceTabId } + : {}) + }) } : {}) }) @@ -190,6 +196,13 @@ export async function commitStructuredAgentSessionCreate(args: { if (!result.ok || !prepared.tab) { return result } + // A chat closed while it was being made gets no tab to show before main closes it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + structuredAgentSessionClosedBeforeCreate(result.value.sessionId) + ) { + return result + } const surfaceTabId = prepared.attachParams.surfaceTabId try { await args.runtime.publishStructuredAgentSessionTab({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index a25d40b52b2..4adf5e6926c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -58,6 +58,11 @@ import { UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + noteStructuredAgentSessionClose, + refusingStructuredAgentSessionCreatesClosedFirst +} from './structured-agent-session-close-before-create' export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ @@ -103,7 +108,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.create', permission: 'workspace', params: CreateParams, - handler: handleStructuredAgentSessionCreate + handler: ORCA_LAYOUT_OWNER_IS_MAIN + ? refusingStructuredAgentSessionCreatesClosedFirst(handleStructuredAgentSessionCreate) + : handleStructuredAgentSessionCreate }), defineMethod({ name: 'agentSession.ensure', @@ -132,6 +139,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: OptionsParams, handler: async (params, ctx) => { const host = requireStructuredCleanupHost(ctx) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + noteStructuredAgentSessionClose( + params.sessionId, + host.deps.store.getRecord(params.sessionId) !== null + ) + } // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts similarity index 96% rename from src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts rename to src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts index 3b1a190ab0c..fc84802df59 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts @@ -1,18 +1,18 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement' -import type { StructuredLaunchState } from './structured-agent-session-launch-registry' +import type { StructuredLaunchState } from '../structured-agent-session-launch-registry' import { hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchCancelled, retireAbsentStructuredAgentSessionLaunchCancellationTombstones, resetStructuredAgentLaunchRegistryForTests, setStructuredLaunchState -} from './structured-agent-session-launch-registry' +} from '../structured-agent-session-launch-registry' import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation' -import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' +import { resetStructuredAgentLaunchPersistenceForTests } from '../structured-agent-session-launch-persistence' import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh' const WORKTREE_ID = 'repo-1::worktree-1' diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.ts b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.ts similarity index 92% rename from src/renderer/src/lib/structured-agent-session-launch-cancellation.ts rename to src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.ts index c56e1af69b8..85bbfcc837e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.ts +++ b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.ts @@ -1,11 +1,15 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' +// The window's cancel tombstones: a chat cancelled before its create landed is hidden and closed when +// its host later lists it. Kept for paired hosts, which may not refuse a create whose close came +// first; the remote-server project sets their floor. Local chats need them only in window-owned +// builds (ORCA_LAYOUT_OWNER), until phase F. +import type { ExecutionHostId } from '../../../../shared/execution-host' import { hasStructuredAgentLaunchCancellationTombstonePersisted, markStructuredAgentLaunchCancelledPersisted, readStructuredAgentLaunchCancellationTombstoneSessionIds, retireAbsentStructuredAgentLaunchCancellationTombstonesPersisted, retireStructuredAgentLaunchCancellationTombstonePersisted -} from './structured-agent-session-launch-persistence' +} from '../structured-agent-session-launch-persistence' type CancellationRetirement = { retireAfterInventory: number | null diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 076ecf18486..18167e87052 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react' import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredLaunchFailure } from './structured-agent-session-launch-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' -import type { ExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery' import type { StructuredLaunchSelection } from './structured-agent-session-launch-options' import type { @@ -22,7 +22,9 @@ import { resetStructuredAgentLaunchCancellationForTests, retireAbsentStructuredAgentLaunchCancellations, retireStructuredAgentLaunchCancellation -} from './structured-agent-session-launch-cancellation' +} from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { cancelStructuredAgentLaunchOnLocalHost } from './structured-agent-session-local-launch-cancel' export type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string @@ -227,17 +229,32 @@ function markStructuredAgentSessionLaunchCancelledInternal( const state = getStructuredLaunchStateBySessionId(sessionId) if (matchesLaunchWorktree(state, worktreeId) && state) { // The launch's own owner outranks the caller's: it is the host the create was sent to. - markStructuredAgentLaunchCancellation( - sessionId, - state.intent.executionHostId, - alreadyCancelled, - state.promise - ) + if (ORCA_LAYOUT_OWNER_IS_MAIN && state.intent.executionHostId === LOCAL_EXECUTION_HOST_ID) { + cancelStructuredAgentLaunchOnLocalHost( + worktreeId, + sessionId, + notifyStructuredLaunchListeners, + state.promise + ) + } else { + markStructuredAgentLaunchCancellation( + sessionId, + state.intent.executionHostId, + alreadyCancelled, + state.promise + ) + } state.cancelled = true state.callers.outcome = 'cancelled' // The tombstone is the durable authority; drop the in-memory launch so bulk closes cannot // retain a dead promise for the lifetime of the renderer. deleteStructuredLaunchStateIfCurrent(state) + } else if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + !alreadyCancelled && + executionHostId === LOCAL_EXECUTION_HOST_ID + ) { + cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId, notifyStructuredLaunchListeners) } else if (!alreadyCancelled) { markStructuredAgentLaunchCancellation(sessionId, executionHostId, alreadyCancelled) } diff --git a/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts new file mode 100644 index 00000000000..17fc334ac03 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts @@ -0,0 +1,40 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import { retireStructuredAgentSessionTab } from '@/runtime/structured-agent-session-tab-retirement' +import { + deleteStructuredAgentLaunchRecord, + hasStructuredAgentLaunchCancellationTombstonePersisted +} from './structured-agent-session-launch-persistence' +import { markStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' + +/** + * ORCA_LAYOUT_OWNER=main only: this machine's host refuses a create whose close came first, or + * closes the chat as it lands, so a local cancel retires the chat on the host at once instead of + * keeping a tombstone until the host lists it. A host that cannot be told gets the tombstone. + */ +export function cancelStructuredAgentLaunchOnLocalHost( + worktreeId: string, + sessionId: string, + onTombstoned: () => void, + launchPromise?: Promise +): void { + deleteStructuredAgentLaunchRecord(sessionId) + void retireStructuredAgentSessionTab({ + target: { kind: 'local' }, + worktreeId, + sessionId, + onError: (error) => { + // A create that has not landed has no host tab yet; main still heard the close. + if (hasRuntimeRpcErrorCode(error, 'tab_not_found')) { + return + } + markStructuredAgentLaunchCancellation( + sessionId, + LOCAL_EXECUTION_HOST_ID, + hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId), + launchPromise + ) + onTombstoned() + } + }) +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts index 63109c51d25..f369c5a1089 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts @@ -9,7 +9,7 @@ import { applyStructuredSessionTabSnapshots } from './snapshot-apply' import { beginStructuredAgentSessionAuthoritativeInventory, startStructuredAgentLaunchCancellationCleanup -} from '../../lib/structured-agent-session-launch-cancellation' +} from '../../lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' import { closeStructuredAgentSession } from '../structured-agent-session-close' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { callRuntimeRpc } from '../runtime-rpc-client' diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 9ece006f78b..691e78e4cf6 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -29,7 +29,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { beginStructuredAgentSessionAuthoritativeInventory, startStructuredAgentLaunchCancellationCleanup -} from '../../lib/structured-agent-session-launch-cancellation' +} from '../../lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' diff --git a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts index 6ea3a350702..f43dc16de2a 100644 --- a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts +++ b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts @@ -37,7 +37,7 @@ import { beginStructuredAgentSessionAuthoritativeInventory, resetStructuredAgentLaunchCancellationForTests, startStructuredAgentLaunchCancellationCleanup -} from '@/lib/structured-agent-session-launch-cancellation' +} from '@/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' import { resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord diff --git a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts new file mode 100644 index 00000000000..afcf5cd8ae8 --- /dev/null +++ b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom + +// With ORCA_LAYOUT_OWNER=main, this machine's host is told about a cancel instead of the window +// keeping a tombstone; paired hosts keep the tombstone in both orders. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { ExecutionHostId } from '../../../shared/execution-host' + +const mocks = vi.hoisted(() => ({ + layoutOwnerIsMain: true, + closeSession: + vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(), + callRuntime: + vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>() +})) + +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) +vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({ + discardStructuredAgentSessionChatSends: vi.fn() +})) +vi.mock('./structured-agent-session-close', () => ({ + closeStructuredAgentSession: mocks.closeSession +})) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntime })) +vi.mock('./local-session-tab-close-owner', () => ({ + withLocalSessionTabCloseOwner: async (_w: string, _t: string, close: () => Promise) => + close() +})) +vi.mock('./runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +import { + getStructuredAgentSessionLaunchLifecycle, + hasStructuredAgentSessionLaunchCancellationTombstone, + markStructuredAgentSessionLaunchCancelled, + resetStructuredAgentLaunchRegistryForTests, + setStructuredLaunchState, + subscribeStructuredAgentLaunchStatus, + type StructuredLaunchState +} from '@/lib/structured-agent-session-launch-registry' +import { + readStructuredAgentLaunchRecord, + resetStructuredAgentLaunchPersistenceForTests, + writeStructuredAgentLaunchRecord +} from '@/lib/structured-agent-session-launch-persistence' +import { + acceptPairedHostStructuredSessions, + beginStructuredAgentSessionTabClose, + suppressCancelledStructuredSessionTabs +} from './structured-agent-session-tab-retirement' + +const WORKTREE = 'repo-1::worktree-1' +const SESSION = 'chat-1' +const LOCAL: RuntimeClientTarget = { kind: 'local' } +const SERVER: RuntimeClientTarget = { kind: 'environment', environmentId: 'server-1' } +const SERVER_HOST = 'runtime:server-1' + +function hostFrame(): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: `agent-session:${SESSION}`, + title: 'Codex Chat', + sessionId: SESSION, + agent: 'codex', + isActive: false + } + ] + } +} + +/** A launch whose create is still on its way to `executionHostId`. */ +function pendingLaunch(executionHostId: ExecutionHostId, target: RuntimeClientTarget): void { + setStructuredLaunchState({ + identity: `codex:${WORKTREE}`, + intent: { + worktreeId: WORKTREE, + executionHostId, + target, + sessionId: SESSION, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION, + clientOperationId: 'operation-1', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-1' + }, + worktree: `id:${WORKTREE}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise: new Promise(() => undefined), + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.layoutOwnerIsMain = true + mocks.closeSession.mockResolvedValue('closed') + mocks.callRuntime.mockResolvedValue(undefined) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() +}) + +describe('this machine, with main owning the layout', () => { + const hostTabClose = [ + LOCAL, + 'session.tabs.close', + { worktree: `id:${WORKTREE}`, tabId: `agent-session:${SESSION}`, reason: 'user' } + ] + + it('cancel before the create: retires the chat on main at once and keeps no tombstone', async () => { + // The create has not landed, so main has no tab for it yet. + mocks.callRuntime.mockRejectedValueOnce(new Error('tab_not_found')) + pendingLaunch('local', LOCAL) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local') + await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalledWith(...hostTabClose)) + + expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION) + // Let the retirement settle, so a tombstone written on its answer would show. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, SESSION)).toBeNull() + }) + + it('create before the cancel (an aborted launch): closes the chat and its published tab', async () => { + pendingLaunch('local', LOCAL) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local') + + await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalledWith(...hostTabClose)) + expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + }) + + it('cancel after a reload, when only the saved launch is left: tells main and forgets it', () => { + writeStructuredAgentLaunchRecord({ + sessionId: SESSION, + executionHostId: 'local', + agent: 'codex', + lifecycle: 'pending', + clientOperationId: 'operation-1', + payloadFingerprint: 'fingerprint-1', + expectedRuntimeFence: null + }) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local') + + expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION) + expect(readStructuredAgentLaunchRecord(SESSION)).toBeUndefined() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + }) + + it('a main that cannot be told gets the tombstone, so a late listing is still hidden', async () => { + mocks.closeSession.mockRejectedValueOnce(new Error('host unavailable')) + pendingLaunch('local', LOCAL) + const listener = vi.fn() + subscribeStructuredAgentLaunchStatus(listener) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local') + await vi.waitFor(() => + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) + ) + + expect(listener).toHaveBeenCalledTimes(2) + expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toEqual([]) + }) + + it('switch off: keeps the tombstone and leaves the close to the tab close, as today', () => { + mocks.layoutOwnerIsMain = false + pendingLaunch('local', LOCAL) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local') + + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) + expect(mocks.closeSession).not.toHaveBeenCalled() + }) +}) + +describe('a paired server, with main owning the layout', () => { + it('cancel before the create: keeps the tombstone and hides and closes the late listing', () => { + pendingLaunch(SERVER_HOST, SERVER) + + markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, SERVER_HOST) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) + expect(mocks.closeSession).not.toHaveBeenCalled() + + expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toEqual([]) + expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION) + }) + + it('create before the cancel: the published chat closes as any chat does, no tombstone', () => { + pendingLaunch(SERVER_HOST, SERVER) + expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toHaveLength(1) + + // Once the create has answered, the tab is not provisional, so its close cancels nothing. + beginStructuredAgentSessionTabClose({ + target: SERVER, + worktreeId: WORKTREE, + sessionId: SESSION, + provisional: false + }) + + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION) + }) +})