Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/scripts/orchestration-skill-guidance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ describe('owned orchestration references', () => {
'A Delivery therefore always carries the whole FIFO batch whatever its types, and a `check` without `--wait` hands that batch over unfiltered'
)
expect(reference).toContain('send --to dispatch:<dispatch_id>')
for (const group of ['@all', '@grok', '@cursor', '@worktree:<id>']) {
for (const group of ['@all', '@idle', '@<agent>', '@worktree:<id>']) {
expect(reference).toContain(group)
}
expect(reference).toContain('Dispatch lifecycle messages never target groups')
Expand Down
3 changes: 2 additions & 1 deletion docs/site/content/docs/cli/orchestration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
5 changes: 3 additions & 2 deletions skill-guides/orchestration/references/messaging-and-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/bundled-skill-guides.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/cli/specs/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
64 changes: 63 additions & 1 deletion src/main/runtime/orchestration/groups.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
})
})
66 changes: 31 additions & 35 deletions src/main/runtime/orchestration/groups.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -7,35 +8,33 @@ 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]
// Use the canonical catalog so newly supported agents are addressable immediately.
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:'

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'
// Distinguish an unknown group from a known group with no current members.
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))
)
}

/**
Expand All @@ -53,11 +52,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(
Expand Down Expand Up @@ -86,8 +82,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)
Expand All @@ -96,19 +92,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 []
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')])

Expand Down Expand Up @@ -628,4 +644,80 @@ 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}` }]
})
})

it('can retry a group after the host learns the pane identity', async () => {
const sender = makeSummary('term_coord', { agentIdentity: 'claude' })
let terminals: RuntimeTerminalSummary[] = [sender, makeSummary('term_ag')]
setupWithTerminals(terminals)
const dispatch = dispatchWorker('term_ag')
vi.mocked(runtime.listTerminals).mockImplementation(async () => ({
terminals,
totalCount: terminals.length,
truncated: false
}))

// A pane that has not reported its identity must not receive an agent-group message,
// and the failed lookup must not leave an orphaned mailbox row.
await expect(
call('orchestration.send', {
from: 'term_coord',
to: '@antigravity',
subject: 'before ready'
})
).rejects.toMatchObject({ code: 'terminal_not_found' })
expect(db.getInbox(100)).toHaveLength(0)

terminals = [sender, makeSummary('term_ag', { agentIdentity: 'antigravity' })]
const result = await call('orchestration.send', {
from: 'term_coord',
to: '@antigravity',
subject: 'after ready'
})

expect(result).toMatchObject({
messages: [{ to_handle: `dispatch:${dispatch}` }]
})
expect(db.getUnreadMessages(`dispatch:${dispatch}`)).toHaveLength(1)
})

// 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'
})
})
})
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -137,6 +137,14 @@ export async function sendGroupMessage(args: {
return runId
}

// Report typos before Run-binding failures obscure the invalid address.
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()
Expand Down
Loading