Files
orca/src/main/runtime/structured-agent-session-close.test.ts
T
Brennan Benson 9fdd90ffa7 feat(orchestration): a native chat can be a dispatch worker, like a terminal agent (#22972)
* feat(native-chat): a queued card can record a Dispatch's task as its source

A chat worker's task is held in the chat's queue like any agent message, so
the card needs to say which Dispatch it is from: the sender, run, task and
Dispatch ids. An older build reads an unknown kind as the person's card and
sends it as written.

* feat(orchestration): a chat can be a dispatch worker, like a terminal agent

`dispatch --to orca_session_id:<id>` and `worker-start --terminal
orca_session_id:<id>` now accept a chat on this host instead of refusing it.

- The chat is refused only where mail to it would be: unknown, a provider id,
  another host, or closed. A chat can't be its own coordinator's worker.
- Its task goes through sendAgentTurn as a queued send, as mail notices do:
  an idle chat starts a turn, a busy one holds a card naming the Dispatch.
  The operation id is derived from the Dispatch, so a resend replays.
- worker-start reads that outcome as the terminal path reads its write; a
  card held behind a running turn is handed over with its start unobserved.
- The Dispatch names the chat by its /clear root, with no pane or process,
  and records its Orca session id, so its own sub-dispatches nest under it.
- Mail to dispatch:<id> reaches the chat; worker-show/list/read read the
  chat's session records; stop and abandon never close the chat; closing the
  chat fails its Dispatch as closing a terminal does.
- A dispatch preamble for a structured session names the CLI the structured
  mail lane names.

* test(orchestration): a chat worker's reach, and its Dispatch across a /clear

At rest is live, a closed chat has exited, and another host or nothing to
read is unverifiable. A /clear keeps the chat's Dispatch, and the session
that continues the chat reports as it.

* fix(orchestration): chat worker review round 1

- dispatch --inject is a keepalive-backed wait, so a chat that takes a while
  to accept its task no longer reads as a dead runtime at the 30 s idle cut.
- An injected task whose delivery is unknown keeps its Dispatch open, as an
  unknown worker-start does, instead of failing it while the chat may run it.
- A busy chat's worker-start receipt says the task waits as a card in the
  chat's queue, and that worker-abandon does not remove that card.
- A close that puts the chat's tab back no longer fails its Dispatch: the
  closed-chat settle runs after the close's outcome, not at its hide.
- worker-start adopting a chat says it gave the task to the chat, instead of
  claiming it started a terminal agent; the mode value is unchanged.
- Test: a chat whose agent has not taken its task reads outcome_unknown.

* test(native-chat): a close's hide defers its hidden notice until the close settles

The rollback suite pinned the hide's arguments; it now expects the deferred
notice, and that the notice is sent once, after a rolled-back tab is back.

* fix(orchestration): a chat worker whose successor is unknown is unverifiable, not exited

A /clear successor this host has no record of is missing evidence, not proof
the chat is gone. Only a closed chat reads exited, so only a closed chat
settles its Dispatch; a lost successor reads unverifiable with that reason.

* fix(orchestration): chat worker review round 2

- A chat's close notice settles only that chat's Dispatch. A scan of every
  chat Dispatch read another chat whose close was still in flight (its tab
  hidden, maybe to be put back) as closed and failed its Dispatch.
- Only a dispatch --inject into a chat is a long poll; a terminal inject
  writes and returns, and keeps its short-RPC slot.
- Receipt wording: worker-start says it gave the task to the chat, and a
  queued task is sent when the chat's queue reaches it.

* fix(orchestration): chat worker wording, review round 3

- worker-start's mode sentence for a chat states the placement, which is
  true whether the task is delivered, queued or refused.
- Comments and a test title no longer claim a close notice re-derives every
  chat worker, or that a queued task waits for the current turn.

* fix(orchestration): build a structured worker's task sender from the start's own ids

The minted worker's preamble names who its task is from with the run, task
and Dispatch ids worker-start already holds, so it reads no Dispatch row.

* fix(orchestration): point a chat worker at its held Dispatch mail again

A chat worker's coordinator mail lands in its Dispatch's mailbox, but a
chat's idle edge re-derived the Dispatch mailbox only for a party with a
terminal handle. A pointer lost with the provider then waited for new mail
after a restart or a /clear. The re-derivation now looks the Dispatch up by
the party's address, which names a worker by its handle and a chat by its
root.

* fix(native-chat): open a task card's sender by address; a task carries no mail

Opening an agent message's sender looked up the mail it carried, which only a
mail notice has; with the task kind in the union that read no longer typed.
A task's coordinator is found by its address alone.
2026-10-06 18:36:21 -07:00

258 lines
9.3 KiB
TypeScript

/**
* The chat tab must survive a close that did not land.
*
* `closeStructuredAgentSessionChild` hides the tab BEFORE it issues the close, so every failure
* shape past that point used to leave the user's chat tab pulled out of the durable restore index
* for a session that is still running — a destructive operation that refused, and still took
* something away.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
const hostRef: { current: unknown } = { current: null }
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => hostRef.current
}))
const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close')
const SESSION = 'session-1'
const TAB_ID = 'tab-of-session-1'
function record(sessionId: string): AgentSessionRecord {
return {
sessionId,
provider: 'claude',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'repo_1::/tmp/wt-a',
workspaceKind: 'folder'
},
lease: {
sessionId,
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null,
runtimeFence: 1,
deathEvidence: null
}
} as unknown as AgentSessionRecord
}
type HostOptions = {
/** Sessions the host keeps holding through a close, so the post-close observation is `live`. */
stuck?: boolean
/** Rejects the close, without the child going. */
closeThrows?: Error
/** The child dies and is recorded dead, but the close then fails past that proof. */
settledThenThrows?: boolean
/** Rejects the visibility write itself, so the hide never lands. */
visibilityThrows?: Error
/** Sessions already in the persisted visible-tab index. */
visible?: string[]
/** Blows up the index read, so the rollback cannot prove the tab was ever visible. */
indexThrows?: boolean
}
/** The hide a close sends; its hidden notice waits for the close's outcome. */
const HIDE_PENDING_CLOSE = [SESSION, false, undefined, { deferHiddenNotice: true }]
function installHost(options: HostOptions = {}) {
const entry = record(SESSION)
const held = new Set([SESSION])
const visible = new Set(options.visible ?? [SESSION])
const setSessionTabVisibility = vi.fn(async (sessionId: string, isVisible: boolean) => {
if (options.visibilityThrows) {
throw options.visibilityThrows
}
if (isVisible) {
visible.add(sessionId)
} else {
visible.delete(sessionId)
}
})
const notifySessionTabHidden = vi.fn()
const close = vi.fn(async (sessionId: string) => {
if (options.closeThrows) {
throw options.closeThrows
}
if (options.stuck) {
return
}
held.delete(sessionId)
entry.lease.claimStatus = 'released'
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
if (options.settledThenThrows) {
throw new Error('the event sink could not be flushed')
}
})
const log = recordingStructuredAgentSessionLogger()
hostRef.current = {
deps: {
logger: log.logger,
store: {
getRecord: (id: string) => (id === SESSION ? entry : null),
getSessionTabId: (id: string) => {
if (options.indexThrows) {
throw new Error('visible tab index unreadable')
}
return visible.has(id) ? TAB_ID : null
}
}
},
hasSession: (sessionId: string) => held.has(sessionId),
setSessionTabVisibility,
notifySessionTabHidden,
close
}
return { close, setSessionTabVisibility, notifySessionTabHidden, visible, log }
}
describe('closeStructuredAgentSessionChild tab-visibility rollback', () => {
beforeEach(() => {
hostRef.current = null
vi.restoreAllMocks()
})
it('retires the tab and reports the close on the success path', async () => {
const host = installHost()
const retire = vi.fn(() => true)
const outcome = await closeStructuredAgentSessionChild(SESSION, {
runtime: {
retireStructuredAgentSessionTabFromSnapshot: retire
} as never
})
expect(outcome).toEqual({ stopped: true, closeAttempted: true })
expect(host.visible.has(SESSION)).toBe(false)
expect(retire).toHaveBeenCalledWith(SESSION)
// The hide is the only visibility write a settled close performs.
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('restores the tab when the close throws and the child is still there', async () => {
const host = installHost({ closeThrows: new Error('provider round trip failed') })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(outcome.reason).toBe('provider round trip failed')
expect(host.visible.has(SESSION)).toBe(true)
expect(host.setSessionTabVisibility.mock.calls).toEqual([
HIDE_PENDING_CLOSE,
[SESSION, true, TAB_ID]
])
// Sent once, after the tab is back: a chat worker's Dispatch reads the chat as open.
expect(host.notifySessionTabHidden.mock.calls).toEqual([[SESSION]])
expect(host.notifySessionTabHidden.mock.invocationCallOrder[0]).toBeGreaterThan(
host.setSessionTabVisibility.mock.invocationCallOrder[1]!
)
})
it('restores the tab when the post-close observation is not `exited`', async () => {
const host = installHost({ stuck: true })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(host.visible.has(SESSION)).toBe(true)
expect(host.setSessionTabVisibility.mock.calls).toEqual([
HIDE_PENDING_CLOSE,
[SESSION, true, TAB_ID]
])
})
it('leaves the tab retired when a close throws PAST a proven exit', async () => {
// `closeStructuredSessionsForWorktree` re-observes and counts this session closed; republishing
// the tab here would resurrect it at the next launch for a workspace that is gone.
const host = installHost({ settledThenThrows: true })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('does not put the tab back when the caller is discarding the workspace anyway', async () => {
// Worktree teardown passes this off for a removal that cannot refuse — force, and the
// folder-workspace paths. A tab put back there is a durable reference to a workspace that is
// about to be gone, so it republishes the chat at the next launch pointing at it.
const host = installHost({ stuck: true })
const outcome = await closeStructuredAgentSessionChild(SESSION, {
restoreTabOnUnprovenClose: false
})
expect(outcome.stopped).toBe(false)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('does not publish a tab for a session that was already hidden', async () => {
const host = installHost({ closeThrows: new Error('provider round trip failed'), visible: [] })
await closeStructuredAgentSessionChild(SESSION)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('does not roll back a visibility write that never landed', async () => {
const host = installHost({ visibilityThrows: new Error('visibility write failed') })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome).toEqual({
stopped: false,
closeAttempted: false,
reason: 'visibility write failed'
})
expect(host.close).not.toHaveBeenCalled()
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('keeps the original failure when the restore itself throws', async () => {
const host = installHost({ stuck: true })
const restoreFailure = new Error('agent_session_identity_required')
host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => {
if (isVisible) {
throw restoreFailure
}
})
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(outcome.reason).not.toContain('agent_session_identity_required')
expect(host.log.entries.map((entry) => entry.fields)).toEqual([
{ scope: 'close-tab-restore', sessionId: SESSION, error: restoreFailure }
])
})
it('claims nothing when the visible-tab index cannot be read', async () => {
const host = installHost({ indexThrows: true, closeThrows: new Error('boom') })
await closeStructuredAgentSessionChild(SESSION)
expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE])
})
it('reports no close attempt when no host is installed', async () => {
hostRef.current = null
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(false)
})
})