mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
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:
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user