mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
* 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.
176 lines
7.6 KiB
TypeScript
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
|
|
})
|
|
}
|
|
}
|