Files
orca/src/shared/agent-session-message-source.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

124 lines
4.8 KiB
TypeScript

// Who a chat message is from when it is not the person at the composer: another agent, through
// Orca. Carried on the message body (`AgentJournalMessageItem.from`), outside every fingerprint.
import { z } from 'zod'
import { normalizeAgentSessionConversationName } from './agent-session-conversation-name'
import { isOrcaSessionId, type OrcaSessionId } from './orca-session-address'
import type { OrchestrationPartyIdentity } from './orchestration-party-identity'
import { openEnum, salvagedField, salvagedOptional, salvagingArray } from './zod-salvage'
/**
* An agent a message is from, named by the orchestration database of the host that stores the
* message: a federated sender arrives as its `dispatch:<id>` address there. No pane key: it reads
* and consumes that agent's mailbox, so the host resolves it from the address.
*/
export type AgentMessageSender = Readonly<{
party: Omit<OrchestrationPartyIdentity, 'paneKey'>
/** What Orca called the sender when it wrote, kept for when it is gone; null when it had none. */
name: string | null
}>
/** One orchestration message a notice points at: its record, and its sender's `senders` address. */
export type OrchestrationMailMessage = Readonly<{ messageId: string; runId: string; from: string }>
/** "You have N orchestration messages": the pointer a terminal agent is typed, for a mailbox's
* unread mail, which the agent reads with `check`. */
export type OrchestrationMailNotice = Readonly<{
message: 'mail-notice'
mailbox: string
dispatchId: string | null
messages: readonly OrchestrationMailMessage[]
}>
/** A Dispatch's task, sent as the assignee's turn: the records it joins back to while they exist. */
export type OrchestrationTaskMessage = Readonly<{
message: 'task'
runId: string
taskId: string
dispatchId: string
}>
/** What Orca delivers for other agents, one shape per message kind. */
export type OrchestrationAgentMessage = OrchestrationMailNotice | OrchestrationTaskMessage
export type AgentMessageSource = Readonly<{
kind: 'agent'
/** Every distinct sender of the messages it carries, in mail order. */
senders: readonly AgentMessageSender[]
/** Null when this build cannot read the kind a newer one wrote; the senders still stand. */
orchestration: OrchestrationAgentMessage | null
}>
/** Who a send is from, as its submission records it: the kind only (`AgentJournalSubmission.source`),
* so a restart or a close keeps only a person's unsent send as a card. The senders stay on the
* message body. */
export type AgentSessionMessageSource = Readonly<{ kind: 'user' | 'agent' }>
export const USER_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'user' }
export const AGENT_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'agent' }
/** A sender name as stored: bounded and flattened like a conversation name, never raw. */
export function agentMessageSenderName(value: unknown): string | null {
return normalizeAgentSessionConversationName(value)
}
const orcaSessionIdSchema = z.custom<OrcaSessionId>(
(value) => typeof value === 'string' && isOrcaSessionId(value)
)
const mailNoticeSchema = z.object({
message: z.literal('mail-notice'),
mailbox: z.string(),
dispatchId: z.string().nullable(),
messages: z.array(z.object({ messageId: z.string(), runId: z.string(), from: z.string() }))
})
const taskSchema = z.object({
message: z.literal('task'),
runId: z.string(),
taskId: z.string(),
dispatchId: z.string()
})
const senderSchema = z.object({
party: z.object({
address: z.string().min(1),
terminalHandle: z.string().nullable(),
orcaSessionId: orcaSessionIdSchema.nullable()
}),
name: z.unknown().transform(agentMessageSenderName)
})
// Read apart, so a malformed sender or an orchestration kind this build lacks never turns an
// agent's message into the person's.
const sourceSchema = z.object({
kind: openEnum(['agent'], 'agent'),
senders: salvagedField('senders', salvagingArray(senderSchema), () => []),
orchestration: salvagedOptional(
'orchestration',
z.discriminatedUnion('message', [mailNoticeSchema, taskSchema])
).transform((orchestration) => orchestration ?? null)
})
/** The one reader of a stored or published `from`: undefined (the person's) when it is absent
* or not an agent's at all. */
export function readAgentMessageSource(value: unknown): AgentMessageSource | undefined {
if (value === undefined) {
return undefined
}
const parsed = sourceSchema.safeParse(value)
return parsed.success ? parsed.data : undefined
}
/** The senders a label names, and how many more it counts; one notice can carry many. */
export function agentMessageSendersShown(
source: AgentMessageSource,
limit = 3
): { shown: readonly AgentMessageSender[]; more: number } {
return {
shown: source.senders.slice(0, limit),
more: Math.max(0, source.senders.length - limit)
}
}