mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<string, unknown> = {}) =>
|
||||
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<void>()
|
||||
const entered = Promise.withResolvers<void>()
|
||||
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<unknown> | 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<unknown> | 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 } })
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
const createsUnderWay = new Map<string, number>()
|
||||
|
||||
/** `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<Result>
|
||||
): (
|
||||
params: Params,
|
||||
ctx: RpcContext
|
||||
) => Promise<Result | { ok: false; refusal: AgentSessionWireRefusal }> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-4
@@ -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'
|
||||
+6
-2
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<unknown>
|
||||
): 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown>>()
|
||||
}))
|
||||
|
||||
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<unknown>) =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user