mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +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.
124 lines
4.8 KiB
TypeScript
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)
|
|
}
|
|
}
|