fix(orchestration): make every well-known agent addressable as a group

Agent-name group addresses were a hand-kept literal of nine names that drifted
behind the canonical agent list. Twenty-eight launchable agents -- antigravity
among them -- resolved to nobody, and because an unknown group and an empty
group both resolve to zero handles, a misspelling was reported exactly like a
correctly addressed pane with no members.

Derive the addressable set from ALL_TUI_AGENTS, which the compiler makes
exhaustive over TuiAgent, so a new agent is addressable the day it is declared.
Keep the legacy @mimo alias pointing at the mimo-code identity. Add
isRecognisedGroupAddress so orchestration.send rejects an unknown group as
invalid_argument before it reads the sender's Run binding, separating a typo
from an empty group and from a binding problem.

Rewrite the guides generically (@all, @idle, @worktree:<id>, @<agent>) and add a
ratchet test that fails if any doc paragraph enumerates the agent groups again.
This commit is contained in:
OrcaWin
2026-09-13 20:23:07 -07:00
parent 16d1ab81d3
commit c72f556e95
9 changed files with 275 additions and 43 deletions
+2 -1
View File
@@ -115,7 +115,8 @@ orca orchestration dispatch --task <taskId> --to <workerHandle> --inject --json
- Default `check` is the bound Run's oldest unacked Delivery (FIFO). Replay until `--ack`.
- `--peek` / `--all` do not consume mail.
- Group addresses: `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, `@worktree:<id>` — never for `worker_done` / heartbeat.
- Group addresses: `@all`, `@idle`, `@worktree:<id>`, and `@<agent>` for any agent id `--agent` accepts (`@codex`, and every other launchable agent) — never for `worker_done` / heartbeat.
- An address naming no known group is rejected as an unknown group, so a typo does not read as a live group with no members.
- Every group except `@worktree:<id>` means the live Dispatches of the sender's own Run, delivered to their Dispatch mailboxes (or child Run mailboxes for nested coordinators). A sender in no Run is refused; `--run` must match the audience and never grants membership.
- Run groups exclude their owning coordinator. A worker raising a blocker sends to `run:<id>`. `@worktree:<id>` includes coordinators in that workspace.
- Quote PowerShell group addresses: `--to "@all"`.
@@ -39,8 +39,9 @@ coordinator calls; a dispatched worker instead copies the exact `--from` and
capability arguments in its preamble. `check` is the exception: it identifies
its caller with `--terminal`, never `--from`.
Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,
`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`. Every group but
Group addresses are `@all`, `@idle`, `@worktree:<id>`, and `@<agent>` for any
agent id `--agent` accepts, such as `@codex`; an address naming no known group
is rejected rather than resolving to nobody. Every group but
`@worktree:<id>` means the live Dispatches of the sender's own Run. Mail goes
to each `dispatch:<id>` mailbox, except a worker coordinating a child Run
receives it in that `run:<id>` mailbox. A sender bound to no Run is refused;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -70,7 +70,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.',
'To answer a worker question, use orchestration reply --id <msg_id> --body <text> with the same Orca CLI executable.',
'Group addresses (@all, @idle, @codex, ...) reach the live Dispatches of your own Run; a sender in no Run must use run:<id> or dispatch:<id>. @worktree:<id> names one workspace.',
'Group addresses @all, @idle, and @<agent> — any agent id --agent accepts — reach the live Dispatches of your own Run; a sender in no Run must use run:<id> or dispatch:<id>. @worktree:<id> instead names one workspace. Any other @name is rejected as an unknown group.',
'Run groups exclude their owning coordinator; send to run:<id> to raise something with yours. Nested coordinators receive group mail in their child Run mailbox; @worktree:<id> includes workspace coordinators.',
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
"worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.",
@@ -0,0 +1,87 @@
import { join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { scanSourceTree, type ScannedFile } from '../../../shared/source-scan/source-tree-scan'
import { AGENT_GROUP_NAMES } from './groups'
// Why this ratchet: the resolver derives its agent-name groups from the canonical agent list,
// but the guides a coordinator actually reads were hand-kept and fell twenty-eight names behind
// it — `@antigravity` resolved fine while every shipped description called the set a closed
// nine. A paragraph naming two agent groups is enumerating the set and will drift again; one
// name is an example. Write the generic form instead: `@all`, `@idle`, `@worktree:<id>`,
// `@<agent>`. src/cli/bundled-skill-guides.ts is covered transitively — it is generated from
// skill-guides/ and `verify:bundled-skill-guides` keeps it in step.
const repoRoot = resolve(import.meta.dirname, '..', '..', '..', '..')
const DOC_ROOTS = ['skill-guides', 'docs']
const DOC_EXTENSIONS = /\.mdx?$/u
const MAX_AGENT_GROUPS_PER_PARAGRAPH = 1
const agentGroupNames = new Set(AGENT_GROUP_NAMES)
/**
* Why the shared ratchet walk: `docs/site` is a self-contained Next.js app, and its own README
* tells contributors to `pnpm --ignore-workspace install` there, so `docs/site/node_modules`
* (plus `.next`, `.source`, `out`) sits on disk for anyone who has previewed the docs. A plain
* recursive walk read dependency markdown and failed this test on a file the contributor
* neither owns nor can edit. `scanSourceTree` skips those trees and dot-directories.
*/
function docFiles(root: string): ScannedFile[] {
return scanSourceTree(join(repoRoot, root), {
extensions: DOC_EXTENSIONS,
// `isTestFile` matches any path containing `repro`, which is prose in a doc, not a fixture.
includeTests: true
})
}
/** Only backticked `@name` counts, so npm scopes like `@opencode-ai/sdk` are not group addresses. */
function agentGroupsNamed(paragraph: string): string[] {
const named = new Set<string>()
for (const match of paragraph.matchAll(/`@([a-z0-9-]+)`/gu)) {
if (agentGroupNames.has(match[1])) {
named.add(match[1])
}
}
return [...named].sort()
}
function enumerationsIn(file: ScannedFile): string[] {
const contents = file.source.replace(/\r\n/gu, '\n')
let line = 1
return contents.split(/\n[ \t]*\n/u).flatMap((paragraph) => {
const lines = paragraph.split('\n')
const start = line
line += lines.length + 1
const named = agentGroupsNamed(paragraph)
if (named.length <= MAX_AGENT_GROUPS_PER_PARAGRAPH) {
return []
}
const offset = lines.findIndex((text) => agentGroupsNamed(text).length > 0)
return [`${relative(repoRoot, file.path)}:${start + offset} names ${named.join(', ')}`]
})
}
describe('group address documentation', () => {
it('never enumerates the agent-name groups, which drift behind the resolver', () => {
const enumerations = DOC_ROOTS.flatMap((root) => docFiles(root).flatMap(enumerationsIn))
// The remedy belongs in the failure itself; a bare array diff does not suggest one.
expect(
enumerations,
'Name at most one agent group per paragraph and write the set generically: `@all`, `@idle`, `@worktree:<id>`, `@<agent>`.'
).toEqual([])
})
it('reads only the repository docs, not an installed dependency tree under docs/site', () => {
const scanned = DOC_ROOTS.flatMap((root) => docFiles(root).map((file) => file.path))
expect(scanned.length).toBeGreaterThan(0)
expect(
scanned.filter((path) => /[\\/](?:node_modules|out|\.next|\.source)[\\/]/u.test(path))
).toEqual([])
})
it('derives the addressable agent groups from the canonical agent list', () => {
expect(agentGroupNames.has('antigravity')).toBe(true)
expect(agentGroupNames.size).toBeGreaterThan(9)
})
})
+63 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { isGroupAddress, resolveGroupAddress } from './groups'
import { isGroupAddress, isRecognisedGroupAddress, resolveGroupAddress } from './groups'
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
import { ALL_TUI_AGENTS } from '../../../shared/tui-agent-display-names'
function makeSummary(
handle: string,
@@ -213,3 +214,64 @@ describe('resolveGroupAddress', () => {
})
})
})
describe('addressable agent coverage', () => {
// Ratchet: the addressable set is derived from the canonical agent list, so a new agent
// becomes addressable the day it lands instead of waiting for someone to notice a literal.
it('routes every canonical agent id to a pane the host resolved as that agent', () => {
const unaddressable = ALL_TUI_AGENTS.filter((agent) => {
const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: agent })]
return resolveGroupAddress(`@${agent}`, 'sender', terminals, noStatus).length === 0
})
expect(unaddressable).toEqual([])
})
it('routes @antigravity to a live Antigravity pane', () => {
const terminals = [
makeSummary('sender'),
makeSummary('ag_pane', { agentIdentity: 'antigravity' })
]
expect(resolveGroupAddress('@antigravity', 'sender', terminals, noStatus)).toEqual(['ag_pane'])
})
it('keeps the legacy @mimo alias pointing at the mimo-code identity', () => {
const terminals = [
makeSummary('sender'),
makeSummary('mimo_pane', { agentIdentity: 'mimo-code' })
]
expect(resolveGroupAddress('@mimo', 'sender', terminals, noStatus)).toEqual(['mimo_pane'])
})
it('still refuses a title-only match for a newly addressable agent', () => {
const terminals = [
makeSummary('sender'),
makeSummary('codex_pane', { agentIdentity: 'codex', title: 'port the antigravity launcher' })
]
expect(resolveGroupAddress('@antigravity', 'sender', terminals, noStatus)).toEqual([])
})
})
describe('isRecognisedGroupAddress', () => {
it('separates a misspelled agent group from a recognised one', () => {
expect(isRecognisedGroupAddress('@antigravity')).toBe(true)
expect(isRecognisedGroupAddress('@antigravty')).toBe(false)
})
it('recognises every canonical agent id and the legacy alias', () => {
const unrecognised = ALL_TUI_AGENTS.filter((agent) => !isRecognisedGroupAddress(`@${agent}`))
expect(unrecognised).toEqual([])
expect(isRecognisedGroupAddress('@mimo')).toBe(true)
})
it('recognises the identity-free group forms', () => {
expect(isRecognisedGroupAddress('@all')).toBe(true)
expect(isRecognisedGroupAddress('@IDLE')).toBe(true)
expect(isRecognisedGroupAddress('@worktree:wt_1')).toBe(true)
})
// Why: `unknown` is the AgentType sentinel for "no agent identified yet", never a pane identity.
it('does not recognise the unknown-agent sentinel or a bare handle', () => {
expect(isRecognisedGroupAddress('@unknown')).toBe(false)
expect(isRecognisedGroupAddress('term_abc')).toBe(false)
})
})
+48 -35
View File
@@ -1,4 +1,5 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import { ALL_TUI_AGENTS } from '../../../shared/tui-agent-display-names'
import type { OrchestrationAddressableAgent } from './structured-worker-group-addressing'
// Why: group addresses enable broadcast messaging to logical groups of agents.
@@ -7,35 +8,50 @@ import type { OrchestrationAddressableAgent } from './structured-worker-group-ad
// candidates: the sender's Run for every group but `@worktree:<id>`, which names one
// workspace explicitly. There is no host-wide candidate set.
const AGENT_NAME_GROUPS = [
'claude',
'openclaude',
'codex',
'opencode',
'mimo',
'gemini',
'droid',
'grok',
'cursor'
] as const
/** Group names that predate the canonical agent id and must keep resolving. */
const LEGACY_GROUP_NAME_ALIASES: Readonly<Record<string, TuiAgent>> = { mimo: 'mimo-code' }
type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number]
/**
* Group name to the agent id the host publishes for a pane.
*
* Why derived and not hand-kept: the literal this replaced named nine agents and drifted
* behind the canonical list, leaving twenty-eight launchable agents — antigravity among
* them — resolving to nobody, indistinguishable from a typo. `ALL_TUI_AGENTS` comes from a
* record the compiler makes exhaustive over `TuiAgent`, so a new agent is addressable the
* day it is declared.
*/
const GROUP_AGENT_IDS: ReadonlyMap<string, TuiAgent> = new Map<string, TuiAgent>([
...ALL_TUI_AGENTS.map((agent): [string, TuiAgent] => [agent, agent]),
...Object.entries(LEGACY_GROUP_NAME_ALIASES)
])
const WORKTREE_GROUP_PREFIX = '@worktree:'
/** Every agent-name group an address may use, canonical ids and legacy aliases alike. */
export const AGENT_GROUP_NAMES: readonly string[] = [...GROUP_AGENT_IDS.keys()]
export function isGroupAddress(to: string): boolean {
return to.startsWith('@')
}
/** Group name to the agent id the host publishes for a pane. */
const GROUP_AGENT_IDS: Record<AgentNameGroup, TuiAgent> = {
claude: 'claude',
openclaude: 'openclaude',
codex: 'codex',
opencode: 'opencode',
mimo: 'mimo-code',
gemini: 'gemini',
droid: 'droid',
grok: 'grok',
cursor: 'cursor'
/**
* Whether this address names a group Orca knows, members or not.
*
* Why separate from resolution: a misspelled group and a live group with no current members
* both resolve to zero handles, so the sender was told the same thing either way. Callers ask
* this first to report a typo as a typo.
*/
export function isRecognisedGroupAddress(to: string): boolean {
if (!isGroupAddress(to)) {
return false
}
const group = to.toLowerCase()
return (
group === '@all' ||
group === '@idle' ||
group.startsWith(WORKTREE_GROUP_PREFIX) ||
GROUP_AGENT_IDS.has(group.slice(1))
)
}
/**
@@ -53,11 +69,8 @@ const GROUP_AGENT_IDS: Record<AgentNameGroup, TuiAgent> = {
* delivering is visible and recoverable — the sender sees no recipients; delivering to the wrong
* agent is neither.
*/
function terminalIsAgent(
terminal: OrchestrationAddressableAgent,
agentName: AgentNameGroup
): boolean {
return terminal.agentIdentity === GROUP_AGENT_IDS[agentName]
function terminalIsAgent(terminal: OrchestrationAddressableAgent, agentId: TuiAgent): boolean {
return terminal.agentIdentity === agentId
}
export function resolveGroupAddress(
@@ -86,8 +99,8 @@ export function resolveGroupAddress(
}
// @worktree:<id> — all handles in a specific worktree
if (group.startsWith('@worktree:')) {
const worktreeId = to.slice('@worktree:'.length)
if (group.startsWith(WORKTREE_GROUP_PREFIX)) {
const worktreeId = to.slice(WORKTREE_GROUP_PREFIX.length)
return terminals
.filter((t) => t.handle !== senderHandle && t.worktreeId === worktreeId)
.map((t) => t.handle)
@@ -96,19 +109,19 @@ export function resolveGroupAddress(
// Why: agent-name groups (@claude, @droid, etc.) resolve against the identity the HOST
// published for each pane, so the sender can address every instance of an agent without
// knowing their handles — and without a task title being able to redirect the message.
const agentName = group.slice(1) // remove @
if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) {
const agentId = GROUP_AGENT_IDS.get(group.slice(1)) // remove @
if (agentId) {
return terminals
.filter((t) => {
if (t.handle === senderHandle) {
return false
}
return terminalIsAgent(t, agentName as AgentNameGroup)
return terminalIsAgent(t, agentId)
})
.map((t) => t.handle)
}
// Why: unknown groups resolve to empty rather than throwing so callers can
// distinguish "valid group, no current members" from programming errors.
// Why still empty and not a throw: resolution stays total. Callers that must tell an
// unknown group from an empty one ask `isRecognisedGroupAddress` first.
return []
}
@@ -253,6 +253,22 @@ describe('orchestration.send group addresses', () => {
}
)
// Sibling of the case above: when the group itself is misspelled the binding error is the
// wrong diagnosis, and it sent the author looking at Run binding instead of at the typo.
it('reports an unrecognised group as a typo even from a sender in no Run', async () => {
setup(false)
vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) =>
handle === 'term_loner' ? 'tab_loner:leaf_loner' : null
)
await expect(
call('orchestration.send', { from: 'term_loner', to: '@antigravty', subject: 'typo' })
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Unknown group address')
})
})
it('rejects @all from a bound coordinator whose Run has no live Dispatch', async () => {
setupWithTerminals([makeSummary('term_coord'), makeSummary('term_bystander')])
@@ -628,4 +644,45 @@ describe('orchestration.send group addresses', () => {
expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${dispatch.id}`])
}
)
it('delivers @antigravity to the live Antigravity worker of the sender Run', async () => {
setupWithTerminals([
makeSummary('term_coord', { agentIdentity: 'claude' }),
makeSummary('term_ag', { agentIdentity: 'antigravity' })
])
const dispatch = dispatchWorker('term_ag')
const result = await call('orchestration.send', {
from: 'term_coord',
to: '@antigravity',
subject: 'status please'
})
expect(result).toMatchObject({
recipients: 1,
messages: [{ to_handle: `dispatch:${dispatch}` }]
})
})
// The defect: a typo and a correctly addressed live pane both resolved to zero handles and
// produced the same sentence, so the sender could not tell a misspelling from an empty group.
it('separates an unrecognised group name from a recognised group with no members', async () => {
setupWithTerminals([
makeSummary('term_coord', { agentIdentity: 'claude' }),
makeSummary('term_ag', { agentIdentity: 'antigravity' })
])
dispatchWorker('term_ag')
await expect(
call('orchestration.send', { from: 'term_coord', to: '@antigravty', subject: 'typo' })
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Unknown group address')
})
await expect(
call('orchestration.send', { from: 'term_coord', to: '@droid', subject: 'nobody home' })
).rejects.toMatchObject({
code: 'terminal_not_found',
message: 'No recipients resolved for group address: @droid'
})
})
})
@@ -1,7 +1,7 @@
import type { MessagePriority, MessageType, OrchestrationDb } from '../../../../orchestration/db'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { resolveGroupAddress } from '../../../../orchestration/groups'
import { isRecognisedGroupAddress, resolveGroupAddress } from '../../../../orchestration/groups'
import { isEquivalentPaneKey } from '../../../../orchestration/db/pane-key-match'
import { resolveBareOrchestrationRecipient } from './recipient-routing'
import {
@@ -137,6 +137,17 @@ export async function sendGroupMessage(args: {
return runId
}
// Why a distinct code, and why before anything reads the sender's binding: a misspelled
// group resolves to zero handles exactly like a live group with no members, so `@antigravty`
// read the same as a correctly addressed pane -- and from a sender bound to no Run it read as
// a Run-binding problem, pointing at binding instead of at the typo.
if (!isRecognisedGroupAddress(groupAddress)) {
throw new OrchestrationError(
'invalid_argument',
`Unknown group address: ${groupAddress}. Group addresses are @all, @idle, @worktree:<id>, or @<agent>.`
)
}
// `@worktree:<id>` names one workspace explicitly; every other group means the sender's Run.
const worktreeGroup = groupAddress.toLowerCase().startsWith('@worktree:')
let audienceRunId = worktreeGroup ? undefined : resolveAudienceRunId()