Files
orca/src/main/runtime/structured-agent-session-close.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

176 lines
7.6 KiB
TypeScript

/**
* Closing a structured agent session's provider child, and proving it went.
*
* Extracted from `stopStructuredWorker` so that orchestration settlement and worktree teardown
* close a session the SAME way rather than one of them inventing a shorter version. Everything
* dispatch-shaped — dropping the redrive subscription, the registry entry and the parked mail —
* stays with the caller that has a dispatch; this is only the child.
*
* `host.close` returns void and keeps a failed close indexed for retry, so the only settlement
* evidence is the observation AFTER it: a session the host no longer holds and whose lease is no
* longer live is proven gone. Anything else is retained rather than settled.
*/
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type { OrcaRuntimeService } from './orca-runtime'
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
import {
observeStructuredSession,
structuredSessionCloseSettled
} from './structured-worker-authority'
export type StructuredAgentSessionCloseOutcome = {
stopped: boolean
/** Whether a close was actually issued; a receipt must not claim one that never happened. */
closeAttempted: boolean
reason?: string
}
export type StructuredAgentSessionCloseOptions = {
runtime?: Pick<
OrcaRuntimeService,
'forgetStructuredSessionMail' | 'retireStructuredAgentSessionTabFromSnapshot'
>
/**
* Runs after the close is issued and BEFORE the proof is read.
*
* Not after: an unsettled close returns early, so a dispatch released there would keep its
* redrive subscription nudging a session no dispatch owns. Every settlement has to reach it.
*/
afterClose?: () => void
/**
* Whether an unproven close may put the chat tab back in the durable restore index.
*
* On by default, which is the retryable case: a stop that refused and still took the user's tab
* away is the loss the rollback exists to undo. A caller that will discard the WORKSPACE
* whatever this close reports passes false — a tab put back there is a durable reference to a
* workspace about to be gone, and it republishes the chat at the next launch pointing at it.
*/
restoreTabOnUnprovenClose?: boolean
}
export async function closeStructuredAgentSessionChild(
sessionId: string,
options: StructuredAgentSessionCloseOptions = {}
): Promise<StructuredAgentSessionCloseOutcome> {
const host = getStructuredAgentSessionHost()
if (!host) {
// Nothing was reached, so nothing was acted on; the receipt must not claim a close.
return {
stopped: false,
closeAttempted: false,
reason: 'The structured agent-session host is not installed; no session was closed.'
}
}
// Read BEFORE the hide, so a rollback puts the tab back exactly as it was, under the same id.
// Restoring unconditionally would publish a tab for a session that was already hidden — a worker
// started without a chat tab, or one the user had closed — which is a new side effect, not an undo.
const restoreTabIfCloseFails =
options.restoreTabOnUnprovenClose !== false ? readPersistedTabId(host, sessionId) : null
try {
return await closeHiddenStructuredAgentSessionChild(
host,
sessionId,
options,
restoreTabIfCloseFails
)
} finally {
// After the outcome, not at the hide: a close that puts the tab back leaves its chat working.
host.notifySessionTabHidden?.(sessionId)
}
}
async function closeHiddenStructuredAgentSessionChild(
host: StructuredAgentSessionHost,
sessionId: string,
options: StructuredAgentSessionCloseOptions,
restoreTabIfCloseFails: string | null
): Promise<StructuredAgentSessionCloseOutcome> {
// Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a
// running child, and a receipt that still said `closed_agent_terminal` for it would be the
// close-that-never-happened this flag exists to rule out.
let closeAttempted = false
try {
await host.setSessionTabVisibility?.(sessionId, false, undefined, { deferHiddenNotice: true })
closeAttempted = true
// A worktree teardown or an orchestration stop: not the user closing this chat.
await host.close(sessionId, 'evict')
} catch (error) {
// Only `closeAttempted` proves the hide landed: the store transaction restores its own state on
// failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo.
if (closeAttempted) {
await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails)
}
return {
stopped: false,
closeAttempted,
reason: error instanceof Error ? error.message : String(error)
}
}
options.afterClose?.()
if (!structuredSessionCloseSettled(sessionId)) {
const observation = observeStructuredSession(sessionId)
await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails)
return {
stopped: false,
closeAttempted: true,
reason: observation.reason ?? 'The structured session is still attached after close.'
}
}
// Only past the proof, and structurally unable to throw: the session's chat tab is retired from
// the live snapshot, which `setSessionTabVisibility(false)` above does not do.
retireSettledStructuredWorkerTab(sessionId, options.runtime)
return { stopped: true, closeAttempted: true }
}
function readPersistedTabId(host: StructuredAgentSessionHost, sessionId: string): string | null {
try {
return host.deps.store.getSessionTabId(sessionId)
} catch {
// Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not
// publish one, for the same reason the read exists at all.
return null
}
}
/**
* Puts the chat tab back after a close that did not settle.
*
* The hide is the one visible side effect this function performs before the destructive step, so a
* failed close that kept it left the user's chat tab gone from the durable restore index — the
* conversation survived under `userData`, but nothing brought the tab back at the next launch.
*
* Re-observed first rather than restored outright: a close can throw PAST its own proof and still
* have taken the child with it, and `closeStructuredSessionsForWorktree` reads exactly that,
* counting such a session closed and retiring its tab. Republishing there would resurrect a tab for
* a session that is demonstrably gone, at the next launch, pointing at a deleted workspace.
*
* That observation NARROWS the window; it does not close it. This one and the sweep's are taken a
* store write apart, so a child that dies in between is unverifiable here and exited there — which
* is why the sweep re-drops the tab reference when it takes that proof. Do not delete either half
* on the strength of the other.
*
* Never throws: the caller's `reason` is what the user is asked to act on, and a rollback failure
* must not replace it. `agent_session_identity_required` is the expected one — the record can be
* gone by now, which is itself the exit this restore is declining to undo.
*/
async function restorePersistedTabVisibility(
host: StructuredAgentSessionHost,
sessionId: string,
tabId: string | null
): Promise<void> {
if (tabId === null || structuredSessionCloseSettled(sessionId)) {
return
}
try {
await host.setSessionTabVisibility?.(sessionId, true, tabId)
} catch (error) {
host.deps.logger.warn('restoring the chat tab after a failed close failed', {
scope: 'close-tab-restore',
sessionId,
error
})
}
}