From c4df8628b81281b9f71e9b67204d41eaec45825a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:00:11 -0400 Subject: [PATCH] Add Droid orchestration routing (#4624) Co-authored-by: Orca --- docs/droid-orchestration-group.md | 83 +++++++++++++++++++ skills/orchestration/SKILL.md | 2 +- src/main/runtime/orchestration/groups.test.ts | 22 +++++ src/main/runtime/orchestration/groups.ts | 4 +- .../runtime/rpc/methods/orchestration.test.ts | 17 ++++ src/main/runtime/rpc/methods/orchestration.ts | 2 +- 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 docs/droid-orchestration-group.md diff --git a/docs/droid-orchestration-group.md b/docs/droid-orchestration-group.md new file mode 100644 index 000000000..3200a28bf --- /dev/null +++ b/docs/droid-orchestration-group.md @@ -0,0 +1,83 @@ +# Droid Orchestration Group + +## Problem + +Issue #4560 reports that Orca CLI / orchestration cannot be used with a Droid agent. Droid is already a first-class launchable agent in `src/shared/tui-agent-config.ts:240`, title detection token-matches Droid in `src/shared/agent-detection.ts:38` and `src/shared/agent-detection.ts:397`, and `--inject` accepts a detected running agent through `runtime.isTerminalRunningAgent` in `src/main/runtime/rpc/methods/orchestration.ts:429`. The gap found locally is that orchestration agent groups are hardcoded to `claude`, `openclaude`, `codex`, `opencode`, and `gemini` in `src/main/runtime/orchestration/groups.ts:7`, so `@droid` resolves to no recipients. + +## Root Cause + +The orchestration group resolver has its own closed list of addressable agent-name groups instead of deriving from the agent set that Orca can launch and recognize. Droid was added to the catalog and status paths, but not to this separate group list. + +## Non-Goals + +- Do not change Droid hook installation or Droid CLI launch semantics. +- Do not add a protocol adapter or new orchestration transport. +- Do not broaden `--inject` to send preambles into arbitrary shells. +- Do not change UI layout, styling, or agent picker ordering. + +## Design + +1. Add `droid` to orchestration's agent-name group allowlist. +2. Keep title matching token-based so `@droid` does not match Android paths, titles, or package names. +3. Update orchestration group tests to cover `@droid` positive and Android false-positive cases. +4. Update CLI-facing error/help text and shipped orchestration skill docs where they name example agent groups so Droid is not implied unsupported. + +## Data Flow + +- User sends `orca orchestration send --to @droid ...`. +- CLI calls `orchestration.send`. +- Runtime lists terminal summaries. +- `resolveGroupAddress` sees `@droid`, matches terminal titles with the existing token regex, and returns Droid terminal handles. +- Runtime inserts one message per recipient and delivers pending messages to idle terminals. + +## Edge Cases + +- `@droid` must match `Droid ready` and `Droid - action required`. +- `@droid` must not match `Android build`, `/tmp/android`, or `my-droid-worker`. +- Sender is still excluded from group fan-out. +- Unknown groups continue resolving to an empty list. +- SSH/remote terminals rely on the same terminal summaries and titles, so no local-path assumptions are introduced. + +## Test Plan + +- Unit: `src/main/runtime/orchestration/groups.test.ts` covers `@droid` positive fan-out and Android/path/hyphen false positives. +- Unit: existing `src/shared/agent-detection` coverage remains the title-status source of truth; no changes expected. +- Unit: existing orchestration RPC group fan-out tests should continue passing. +- Manual/CLI: with a Droid terminal title, `orca orchestration send --to @droid --subject ...` should resolve recipients; without one it should report no recipients. + +## UI Quality Bar + +Not UI-visible. Behavior changes only affect CLI orchestration group routing and error/help copy. + +## Review Screenshots + +No required UI screenshots. Stage 6 should capture a terminal/CLI validation artifact only if Electron validation creates a visible terminal state. + +## Rollout + +1. Update orchestration group allowlist and tests. +2. Update example/error copy. +3. Run focused unit tests for group resolution and orchestration send behavior. +4. Run typecheck/lint if the focused tests pass. + +## Lightweight Eng Review + +- Scope: reduced to group resolution and copy because Droid is already in launch, hook, title, and process-recognition paths. +- Architecture/data flow: keep the boundary inside `src/main/runtime/orchestration/groups.ts`; runtime RPC continues delegating fan-out through the existing resolver. +- Failure modes covered: + - Android false positives from substring matching. + - Hyphen/path token false positives. + - Sender exclusion in group fan-out. + - Unknown groups preserving empty-resolution behavior. +- Test coverage required: + - `src/main/runtime/orchestration/groups.test.ts` for `@droid` matching and false positives. + - Existing `src/main/runtime/rpc/methods/orchestration.test.ts` smoke for agent group fan-out. +- Performance/blast radius: no material concern; one string added to a small in-memory list and one extra unit-test case. +- UI quality bar: not UI-visible. +- Required review screenshots: none; validation should rely on CLI/test output unless a visible terminal state is exercised. +- Residual risks: if Droid's real TUI never sets a title containing `Droid`, `@droid` still needs foreground-process-aware group resolution in a follow-up. Current code already synthesizes Droid titles from hooks, so this is expected to work for hook-enabled Droid sessions. + +## Codex Review + +- Round 1: tightened scope to include shipped orchestration skill/help text because it documents the same hardcoded group list users see when learning the feature. +- Residual issues: none known within the small group-routing fix. diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 8fe34a692..33cbe53bf 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -65,7 +65,7 @@ Rules: - While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting. - Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. - `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. -- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, and `@worktree:`. +- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:`. - Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`. ## Tasks And Dispatch diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 73bbbb7ad..7371e0853 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -28,6 +28,7 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@all')).toBe(true) expect(isGroupAddress('@idle')).toBe(true) expect(isGroupAddress('@claude')).toBe(true) + expect(isGroupAddress('@droid')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -131,6 +132,27 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_b']) }) + it('matches @droid by terminal title and excludes sender', () => { + const terminals = [ + makeSummary('term_a', { title: 'Droid ready' }), + makeSummary('term_b', { title: 'Droid ready' }), + makeSummary('term_c', { title: 'Droid - action required' }) + ] + const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b', 'term_c']) + }) + + it('does not match Android, path, or hyphenated tokens through @droid', () => { + const terminals = [ + makeSummary('term_a', { title: 'Codex CLI' }), + makeSummary('term_b', { title: 'Android build' }), + makeSummary('term_c', { title: '/tmp/android' }), + makeSummary('term_d', { title: 'my-droid-worker' }) + ] + const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + it('is case-insensitive for group address', () => { const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })] const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 2989c6141..baa3110f4 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -4,7 +4,7 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Resolution is done at send-time: one message record per recipient, same thread_id, // so each recipient gets their own read-tracking (Section 4.5). -const AGENT_NAME_GROUPS = ['claude', 'openclaude', 'codex', 'opencode', 'gemini'] as const +const AGENT_NAME_GROUPS = ['claude', 'openclaude', 'codex', 'opencode', 'gemini', 'droid'] as const export type GroupAddress = | '@all' @@ -58,7 +58,7 @@ export function resolveGroupAddress( .map((t) => t.handle) } - // Why: agent-name groups (@claude, @codex, etc.) match by terminal title so + // Why: agent-name groups (@claude, @droid, etc.) match by terminal title so // the sender can address all instances of a particular agent type without // knowing their handles. const agentName = group.slice(1) // remove @ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 26e2a1c36..d1e07f913 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -179,6 +179,23 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) + it('fans out @droid by title match', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Codex' }), + makeSummary('term_b', { title: 'Droid ready' }), + makeSummary('term_c', { title: 'Android build' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@droid', + subject: 'droid only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + it('fans out @worktree: to matching worktree', async () => { setupWithTerminals([ makeSummary('term_a', { worktreeId: 'wt_1' }), diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index d6d72a8ce..0f5c76a56 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -431,7 +431,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!hasAgent) { throw new Error( `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + - 'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' + + 'Start an agent CLI (e.g. claude, codex, gemini, droid) in the terminal first, ' + 'or dispatch without --inject and send the prompt manually.' ) }