跳到主要内容

数据截至 (上游 commit 4ec6bbf5884e)

让 agent 反过来开车 — skills、Orca CLI、编排与动作面

30 秒导读: 前五章讲的是"人怎么用 Orca 管一堆 agent"。这一章反过来:Orca 怎么把自己做成一件 agent 能拿起来用的工具——教 agent 认识自己(skills)、给 agent 一个命令面(orca CLI)、把多 agent 协作的每一步钉进 SQLite(orchestration),再把浏览器和桌面变成 agent 能操作的目标(动作面)。


1. 这是什么(零基础也能懂)

一句话定义: 本章讲的是 Orca 的"被调用面"——运行在 Orca 终端里的 coding agent,可以通过一个命令行程序反向操纵 Orca 本身:开 worktree、发消息、派工、点网页、点桌面。

为什么需要它。 普通 IDE 的假设是"人点按钮"。Orca 的假设是"一群 agent 并行干活",于是必须回答一个新问题:

一个正在跑的 agent,怎么知道 Orca 有哪些能力、怎么调用、调用完状态存在哪?

Orca 的回答分成四层,由外到内:

干什么谁在说话
skills让 agent 发现"Orca 有这些能力"一份放进 agent 技能目录的 Markdown
Orca CLIagent 真正打出去的命令orca orchestration send …
RPC命令跨进程送到 runtimeUnix socket / WebSocket(见 05)
runtime + SQLite状态落地、可审计runs / tasks / dispatch_contexts / decision_gates 等表

用起来什么样。 一个被派工的 worker agent,在它的终端里看到的第一段文字不是人写的,是 Orca 注入的 preamble;它照着 preamble 里的命令报告结果:

# agent 自己敲的命令,不是人敲的
orca orchestration send --from worker-3 \
--type worker_done --subject "重构完成" \
--body "改了 3 个文件;发现一处未覆盖分支;剩余:补测试。" \
--task-id task_7f2a --dispatch-id disp_91c4 --outcome succeeded

一句话直觉: 把 Orca 想成一台装了 API 的机床。人是操作工,agent 是机械臂;skills 是贴在机床上的操作说明卡片,CLI 是控制面板,SQLite 是那本谁也改不了的工单台账。


2. 顶层全景(它大概怎么转)

2.1 一次完整闭环

怎么读这张图:从上往下是一次调用的生命周期,左边是 agent 侧,右边是 Orca 侧。

agent 进程 Orca 进程(Electron main)
┌───────────────────────┐
│ ① 读技能卡片 │ skills/<topic>/SKILL.md ← 只写"我是谁、去哪拿正文"
│ (stub,几十行) │
└──────────┬────────────┘
│ 照卡片指示

┌───────────────────────┐
│ ② orca skills get … │──── 本地读内嵌表,不联系 runtime ────┐
│ 拿到完整版指南 │ │
└──────────┬────────────┘ bundled-skill-guides.ts
│ │
▼ ▼
┌───────────────────────┐ RPC ┌──────────────────────────────┐
│ ③ orca <命令> --json │ ────────────────► │ runtime:worktree/终端/浏览器 │
│ (specs 校验 → 懒加载 handler) │ /computer/orchestration │
└───────────────────────┘ ◄──────────────── └───────────────┬──────────────┘
JSON 结果 │

┌───────────────────────┐
│ ④ SQLite:任务 DAG / │
│ dispatch / 门 / 消息│
└───────────────────────┘

2.2 部件一句话职责

部件干什么在哪个文件
技能 stub只讲"何时用我 + 去二进制拿正文"skills/<topic>/SKILL.md(生成物)
技能正文版本严格匹配的完整指南skill-guides/<topic>.md(真源)
内嵌表把正文编译进二进制src/cli/bundled-skill-guides.ts(生成物)
技能发现扫遍各家 agent 的技能目录src/main/skills/discovery.ts
命令规格每条命令的路径/旗标/示例src/cli/specs/*.ts
命令路由key → handler 组,按需加载src/cli/handler-group-manifest.ts
编排库任务 DAG、派工、门、消息src/main/runtime/orchestration/db.ts
协调器轮询驱动 DAG 前进src/main/runtime/orchestration/coordinator.ts
浏览器动作面快照取 ref、按 ref 操作src/main/browser/snapshot-engine.tscdp-bridge.ts
桌面动作面无障碍树 + 注入输入src/main/computer/sidecar-entry.ts + native/computer-use-*

2.3 主线走一遍(不进代码)

  1. 协调 agent 建 Run、建 Task(orchestration task-create)。
  2. 它调 orchestration worker-startdispatch --inject,Orca 开 worktree + 终端,把 preamble 注入进去。
  3. worker agent 读 preamble,干活,期间发 heartbeat,遇事发 ask 阻塞等回答。
  4. worker 发 worker_done,runtime 校验它确实是这个 dispatch 的持有者,再原子结算 task + dispatch。
  5. 依赖它的下游 task 自动从 pending 提升为 ready,协调器下一 tick 派出去。

3. 核心机制一:技能分发的三段式

3.1 它要解决的小问题

技能文件被复制到用户机器的 ~/.claude/skills/ 之类目录后,就再也不会自动更新。而 Orca 是个频繁发版的桌面 app,命令和旗标会变。于是必然出现:

用户去年装的技能文件,教今年的 agent 用一个已经不存在的子命令。

3.2 思路:把"发现"和"正文"拆开

Orca 的取舍很硬:装到磁盘上的那份,永远不写具体命令

skill-guides/<topic>.md ← 唯一真源(388 行,写满命令细节)

├──► 内嵌进二进制 ────► bundled-skill-guides.ts ──► `orca skills get <topic>` 现场吐出
│ (版本必然匹配,因为就是这个二进制)

└──► 换掉正文 ────────► skills/<topic>/SKILL.md
(保留 frontmatter) = frontmatter(不变) + skill-stubs/<topic>.md(几十行,只讲路由)

stub 自己把这条设计写在正文里,skills/orchestration/SKILL.md:19-21:

完整参考"由 orca 二进制自己提供 —— 故意不写进这个文件,这样它永远不会和真正执行你命令的那个二进制脱节"。

3.3 真实实现:一个生成脚本

config/scripts/generate-bundled-skill-guides.mjs 一次产出两类产物。

产物一:内嵌表。 serializeEmbeddedModule(:104-127)把每份 guide 的 Markdown 序列化成 TS 常量,markdownfullMarkdown 目前指向同一个常量(脚本里明说"当前没有 guide 带附加参考文档,所以 --full 逐字节相同")。

产物二:stub 投影。 composeStubProjection(:94-98)只做一件事——取 guide 的 frontmatter 块原样,拼上 stub 正文:

// 真实源码:config/scripts/generate-bundled-skill-guides.mjs:94-98
function composeStubProjection(guideMarkdown, stubBody, sourcePath) {
const block = frontmatterBlock(guideMarkdown, sourcePath)
const body = normalizeMarkdown(stubBody).replace(/^\n+/, '').replace(/\n*$/, '\n')
return `${block}\n${body}`
}

为什么 frontmatter 必须逐字节来自 guide:frontmatter 里的 name + description 是 agent 做技能路由用的唯一判据(哪个技能该被触发),它不能因为"这份是 stub"而变样。

3.4 两条"只进不出"的账本

脚本里有两个数组带着很重的注释,值得单独看:

数组语义为什么只加不删
GUIDE_ALIASES(:23-32)改名后的旧名映射旧 stub 可能在用户磁盘上无限期存活,旧名必须永远认
STUB_TOPICS(:39-48)已瘦身为 stub 的主题迁移是单向的——早先装的"胖"版本要靠 stub 落地才能收敛

requireTopic(src/cli/handlers/skills.ts:52-54)因此把别名和正名塞进同一张查找表,而不是当成临时 CLI alias。

3.5 CI 里的防漂移闸

verifyArtifacts(:231-250)不写文件,只比对磁盘内容与重新生成的内容;不一致就报"generated bundled skill guides are stale"。也就是说:改了 skill-guides/、忘了重新生成,CI 直接红。

3.6 服务端(其实是本地)怎么吐正文

orca skills get 完全不联系 runtime,只读内嵌常量(src/cli/handlers/skills.ts:340-348):

// 真实源码:src/cli/handlers/skills.ts:342-347
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
const guide = requireTopic(flags, guides)
const full = flags.has('full')
const markdown = full ? guide.fullMarkdown : guide.markdown

注意那个 await import:注释(:341-342)说明这是刻意的懒加载——内嵌表很大,不能让 orca status 这种无关命令付它的解析成本。

同一个思路还有一处落到了错误恢复上:当 runtime 判定客户端契约过旧,它返回的不是一句"版本不对",而是一条可执行的下一步(src/shared/orchestration-rpc-contract.ts:9-14, 61-76):

// 真实源码:src/shared/orchestration-rpc-contract.ts:9-14
export const ORCHESTRATION_SKILL_COMMAND_ARGS = ['skills', 'get', 'orchestration', '--full'] as const

即"你手上的说明书过期了,用同一个可执行文件重新取一份"。这把版本漂移变成了 agent 能自愈的循环。


4. 核心机制二:技能装在哪、什么时候能更新

4.1 发现:一次扫十几个家目录

Orca 不假设用户只用一种 agent。buildSkillDiscoverySources(src/main/skills/skill-discovery-sources.ts:65-211)硬编码了一张"各家 agent 的技能目录"清单:

根 id路径providers
home-agents~/.agents/skillsagent-skills(共享目录,canonical)
home-claude~/.claude/skillsclaude
home-codex~/.codex/skillscodex
codex-plugin-cache~/.codex/plugins/cachecodex + agent-skills
home-cursor / home-gemini / home-grok / home-opencode / home-pi / home-omp / home-antigravity各自家目录agent-skills
repo-agents-<hash> / repo-claude-<hash>每个本地 repo 的 .agents / .claude仓库作用域

注释(:108-109)点破了为什么要扫这么全:npx skills add --global 会往每个 agent 自己的家目录写一份,只扫一处就会漏。

扫描本身在 discoverSkills(src/main/skills/discovery.ts:258-316),几个不显然的细节:

  • 深度不对称:plugin 根允许 9 层,其余 4 层(:188)——插件缓存嵌套更深。
  • 符号链接跟进但防环:findSkillFiles(:49-110)跟随目录软链,但用 realpath 去重(:62-65),因为"用户常把技能目录在多个 provider 之间互相 symlink"。
  • 按 realpath 去重、但合并可见性:同一个物理文件被多个根扫到时,保留第一个根的身份,却把 rootPathsproviders 合并(:254-280),否则一个共享技能会在 Settings 里少显示几个 agent 徽章。
  • 上限:单文件读取 256 KB、单包计数 200 个文件(:25-26),防止一个巨型目录拖垮扫描。

Claude 插件是另一条路:discoverClaudePluginSkillSources 要先读 ~/.claude/plugins/installed_plugins.json 和三层 settings(claude-plugin-skill-sources.ts:28-42),按 user → project → project-local 的顺序合并 enabledPlugins,再按 projectPath 判断这条 install 对当前 cwd 是否适用(:95-113)。

4.2 拓扑:同名技能的五六种"存在方式"

同一个技能名可能在磁盘上以完全不同的形态存在,而能不能安全更新取决于形态。classifyHomeSkillTopology(src/main/skills/skill-installation-topology.ts:82-143)把它分成:

topology什么形态全局 update 能碰吗
canonical-copy~/.agents/skills 下的真实拷贝能(它就是锚点)
provider-alias指向 canonical 的软链
independent-copy别家家目录里的独立拷贝不碰
external-link链到树外 / 祖先是软链不碰
read-only目标不可写不碰(会阻断)
repo-scope / plugin-cache仓库内 / 插件缓存不碰

判定顺序值得看:先看自己是不是软链,再看祖先里有没有软链(hasSymlinkedAncestor,:63-80),最后才按根 id 定 canonical(:137:只有 home-agents 算 canonical)。可写性检查放在最后覆盖(:139-141)。

4.3 眼睛只看"命令真会写的那几份"

eligibleSkillUpdateNames(src/main/skills/skill-freshness-eligibility.ts:19-53)是全章我最喜欢的一段"克制"。它的文档注释把规则说得很干净:

  • 只在收敛集(canonical + 它的软链别名)上判定。
  • 那些命令证明不会碰的拷贝(独立副本、项目技能、插件缓存、树外链接),既不授权更新、也不阻止更新——否则徽章要么承诺做不到的事,要么因为一份根本不相干的副本而拒绝该做的事。
  • 但一个收敛副本若处于不可写状态,就要阻止——那才是真正的数据丢失场景。
// 真实源码:src/main/skills/skill-freshness-eligibility.ts:43-51
const hasOutdated = convergent.some((entry) => entry.status === 'outdated')
const everyConvergentCopyIsSafeToWrite = convergent.every(
(entry) =>
(entry.status === 'current' || entry.status === 'outdated') &&
Boolean(entry.resolvedPath && entry.physicalIdentity)
)
if (hasOutdated && everyConvergentCopyIsSafeToWrite) { eligible.push(entries[0].name) }

4.4 执行更新:不信任 stdout,信任重扫

SkillUpdateRunner(src/main/skills/skill-update-run.ts:58-309)包着 npx --yes skills update <names> --global -y(:95)。两处判断很硬:

  • 两个 --yes 各司其职(:49-57):npx --yes 跳过"要装这个包吗",skills … -y 走 CLI 自己的非交互分支;同时 stdin 设成 ignore(:128-130),因为那个 CLI 的交互闸是 options.yes || !process.stdin.isTTY
  • 成败以重扫为准(:196-199):skills update 没有 --json,stdout 不是契约。于是退出码只在重扫拿不到结论时才作数;重扫重新哈希磁盘,那才是用户真正关心的东西。

取消路径同样谨慎:cancel() 先递增 runToken 让旧子进程的回调作废(:236-237),然后用 killWithDescendantSweep 杀整棵树(注释 :248-251:npx 只是壳,真正写全局技能目录的是它的子进程),并且杀完之前不释放 running 状态(:264-272)——否则用户立刻点第二次 Update,会有两个 npx 同时写同一批文件。


5. 核心机制三:CLI 命令面怎么长成的

5.1 一条命令的四段旅程

argv

├─① parseArgs 分离命令路径与旗标(args.ts:86)
├─② normalizeCommandPositionals 位置参数 → 具名旗标、别名归一(args.ts:235)
├─③ validateCommandAndFlags 未知命令/未知旗标在这里就报错(args.ts:278)
└─④ dispatch key → handler 组 → 动态 import → 执行(dispatch.ts:39)

关键顺序在 src/cli/index.ts:88-93:先校验语法,再加载 RuntimeClient。注释说得直白——打错命令的人不该收到"Orca 没在运行"这种误导性错误。

5.2 启动成本是被认真对待的

src/cli/index.ts:39-46 有一条量化注释:RuntimeClient 的依赖图是 CLI 199 个 eager 模块里的 153 个(zod、ws、tweetnacl 都在里面)。所以它被塞进一个 await import,而 --helphelp <cmd>、命令/旗标错误这些在此之前就返回的路径,一分钱都不付。

同一思路贯穿路由层。buildRoutes(src/cli/dispatch.ts:17-33)只用 key→组 建表,组的实现全是 () => import(...):

// 真实源码:src/cli/handler-group-manifest.ts:225-228
{
name: 'skills',
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
}

keys 是手写的镜像,靠 handler-group-manifest.test.ts 与真实导出对拍(注释 :12-13),所以漂移死在 CI 而不是 dispatch。重复注册也会在建表时直接抛错(dispatch.ts:22-27)。

5.3 命令面有多大

HANDLER_GROUPS(src/cli/handler-group-manifest.ts:14)+ BROWSER_HANDLER_GROUPS(src/cli/browser-handler-groups.ts:5)一共 20+ 组。按 agent 的用途归类:

代表命令
工作区worktree / repo / project / fileworktree createfile diff
终端terminalterminal sendterminal wait
编排orchestration(26 个 key)task-createworker-startaskgate-resolve
浏览器browser-nav / interact / tab / profile / cookie / capturesnapshotclick @e12network
桌面computer(14 个 key)get-app-stateclickhotkey
移动emulatoremulator tapemulator ax
自身skills / diagnostics / introspectionskills getagent-context

5.4 为 agent 调用而做的解析细节

args.ts 里几处专门迁就"程序调用"而非"人手敲":

  • --flag=value 是唯一能传 -- 开头值的形式(:98-100):--text=--help 才传得进去,空格形式会把下一个 token 当新旗标。
  • 可重复旗标只有 labelskill,用 \0 拼接(:58-59, 61-68),避免 --skill a --skill b 覆盖。
  • 前置旗标不能吞掉命令路径(:112-118):--json worktree list 里的 --json 不会把 worktree 吃成它的值。
  • 位置参数与同名旗标冲突要报错,而不是静默取一个(:257-258,配 :288-295 的报错)。

5.5 规格即文档

src/cli/specs/*.ts 每条命令带 summary / usage / allowedFlags / notes / examples。这些 notes 是写给 agent 读的行为契约,例如 worker-start(src/cli/specs/orchestration-worker-specs.ts:38):

"只有 ready 才退 0。failed 或 outcome_unknown 退 1,且 JSON 里包含 stage/failedStage、setup、effects、residualResources,必要时还有恢复命令。"

effectiveAllowedFlags(args.ts:188-199)保证校验用的旗标集给 agent 展示的旗标集是同一份,不会出现"help 里有、校验里没有"。


6. 核心机制四:编排后端 —— 把多 agent 协作钉进 SQLite

这是本章分量最重的一节。它回答:多个 agent 协作时,"谁做了什么、谁有权宣布完成"存在哪、怎么防伪。

6.1 五张核心表

OrchestrationDb(src/main/runtime/orchestration/db/orchestration-db.ts:24)在构造时开 WAL、synchronous=NORMALbusy_timeout=5000,建表后跑迁移,最后在 POSIX 上把 db/wal/shm 都 chmod 600(hardenOrchestrationDatabaseFiles,src/main/runtime/orchestration/db/database-file-permissions.ts:1-17)。

上游已把原来的单体 db.ts 按域拆成 src/main/runtime/orchestration/db/ 下的模块树(schema/tasks/runs/messages/dispatch-context/…),OrchestrationDbattachOrchestrationDbMethods 把这些自由函数挂到原型上拼成完整接口(db/orchestration-db.ts:42-45)。

装什么DDL 位置
runs一次编排会话,含 coordinator 的 handle 与 pane keydb/schema/create-core-tables-sql.ts:5
messages全部消息;sequence 是自增主键,id 另建唯一索引db/schema/create-core-tables-sql.ts:17:42
tasks任务节点,deps 是 JSON 数组 → DAG 的边db/schema/create-graph-tables-sql.ts:88
dispatch_contexts一次派工:受派人、capability、心跳、失败计数db/schema/create-graph-tables-sql.ts:113
decision_gates需要人拍板的关卡db/schema/create-graph-tables-sql.ts:140
worker_dispatchesworker 启动过程的复合状态机(stage/effects/残留资源)db/schema/create-core-tables-sql.ts:112
coordinator_runs协调器循环本身的记录db/schema/create-graph-tables-sql.ts:160

再加联邦三件套 federated_dispatches / remote_dispatch_attachments / federation_relay_items(db/schema/create-graph-tables-sql.ts:9, 24, 57),用于"Run 在 Mac、worker 在 Windows"。

状态取值全部落成 SQL 的 CHECK 约束,与 TS 类型一一对应(src/main/runtime/orchestration/types.ts:1-39):

TS 类型取值
MessageTypestatus / dispatch / worker_done / merge_ready / escalation / handoff / decision_gate / question / heartbeat
TaskStatuspending / ready / dispatched / completed / failed / blocked
DispatchStatuspending / dispatched / completed / failed / circuit_broken
GateStatuspending / resolved / timeout
WorkerDispatchStatestarting / ready / start_unknown / failed / succeeded / stopping / stop_unknown / stopped / abandoned

注意 start_unknown / stop_unknown 这对:它们承认"我不知道启动成功没有"是一个一等状态,而不是逼调用方在成功/失败里二选一。

6.2 迁移:一个事务,版本号只在成功时才涨

SCHEMA_VERSION = 29(db/contract-constants.ts:10),上面一行注释是完整的版本编年史。migrate()(db/schema/migrate.ts:7)的写法是:

读 user_version ──► 已是最新? ──是──► 直接返回
│否

BEGIN IMMEDIATE
├─ v1→v2: SQLite 改不了 CHECK,只能重建 messages 表(顺手把 v3 的列一起加,省一次重建)
├─ v2→v3 … v29: 逐档 ALTER / 建表 / 建索引,每步先 hasColumn 探测
└─ pragma user_version = 29 ← 只有全部成功才走到这
COMMIT / 回滚

两处细节:重建 messages 时必须手动重建索引(db/schema/migrate-v2-v12.ts:45-50),因为 DROP TABLE 会带走它们,而 createTables 要到下次启动才跑;每个 ALTER 前的 hasColumn 探测(db/schema/schema-column-probes.ts:3)是为了让"已经通过重建拿到该列"的库不至于因为重复列错误炸掉整个事务。

6.3 DAG 怎么走:两个函数

建任务时就定状态(createTask,db/tasks/task-store.ts:11):INSERT 的 status 列直接用一个 SQL CASE 表达式算出——deps 里存在任何一个"不存在的/不属于同一 Run 的/未 completed 的"依赖,就是 pending,否则 ready(db/tasks/task-store.ts:56-66)。同时校验 parent 和每个 dep 必须属于同一个 Run(db/tasks/task-store.ts:26-41)。

完成时推进下游(promoteReadyTasks,db/tasks/task-store.ts:194):扫所有 pending 任务,凡是 deps 里含刚完成的这个、且全部 deps 都 completed 的,就提升为 ready。它跑在状态更新的同一事务里,所以"完成的任务不会留下没提升的孩子"。

task A ──┐
├──► task C (pending, deps=[A,B])
task B ──┘

└─ B 完成 → promoteReadyTasks(B) → A 也完成? ─是→ C 变 ready → 下一 tick 被派出去
└─否→ C 保持 pending

6.4 结算:防伪造的完成报告

这是整套设计的承重墙。一个 worker 说"我做完了",凭什么信?

settleWorkerReport(db/dispatch-context/worker-report-settlement.ts:6)在一个 BEGIN IMMEDIATE 事务里跑一串检查,失败返回结构化拒绝码而不是抛异常(WorkerReportSettlement,src/main/runtime/orchestration/types.ts:26-37):

检查拒绝码挡的是什么
task 存在unknown_task编造的 task id
dispatch 存在unknown_dispatch编造的 dispatch id
dispatch.task_id == taskIdtask_dispatch_mismatch张冠李戴
双方都还是 dispatchedinactive_dispatch已结算的重复上报
它是该 task 的最新 dispatchstale_dispatch上一次失败重试的迟到完成

若目标状态已经就是要写的状态,直接返回 { settled, duplicate: true }(db/dispatch-context/worker-report-settlement.ts:57-58)——幂等,不报错。

真正写入时还套了一层 SAVEPOINT + 条件更新(db/dispatch-context/worker-report-settlement.ts:100-125):两条 UPDATE 都带 WHERE … status = 'dispatched',只要有一条 changes !== 1ROLLBACK TO,并按 inactive_dispatch 拒绝——这是对"结算过程中状态被改"的最后一道 CAS 式防线。

6.5 授权:pane 才是身份,handle 不是

上一层的 reconcileLifecycleMessage(lifecycle-reconciliation.ts:103)先判断发消息的是不是受派那个人:

// 真实源码:src/main/runtime/orchestration/lifecycle-reconciliation.ts:16-28
function hasLifecycleAuthority(dispatch, msg): boolean {
if (dispatch.assignee_pane_key) {
return Boolean(msg.sender_pane_key && isSamePane(dispatch.assignee_pane_key, msg.sender_pane_key))
}
// pane 身份出现之前建的行,只能用派工时记下的确切 handle
return dispatch.assignee_handle === msg.from_handle
}

isSamePane(lifecycle-reconciliation.ts:7-13)允许 pane key 的 tab 部分变化(分屏拆出时会变),只比 leafId。而 RPC 侧更狠:orchestration.send 里的 sender pane key 不采信调用方传的值,而是用 runtime 现场观测的或 attested hook 身份(src/main/runtime/rpc/methods/orchestration.ts:462-463)。

心跳的授权同等严格,理由写在注释里(lifecycle-reconciliation.ts:159-161):别人的心跳不能刷新你的存活,否则一个挂死的 worker 会被另一个 agent 的计时器掩盖。

还有一处顺手的清理:worker_done 落地后,suppressEarlierHeartbeats(:331-347)把该 dispatch 在此之前的未读心跳全部标记已读——完成之后没人再需要它们。

6.6 CLI 侧的"失败要闭合"

orchestration send 在客户端就先失败:发 worker_done / heartbeat 时如果既没有 --from 也没有 ORCA_TERMINAL_HANDLE,直接报错而不是猜(src/cli/handlers/orchestration.ts:577-584),注释说得好——焦点不是生命周期授权

被拒绝的生命周期消息会把进程退出码设成 1(:554-557),防止 worker 把"发出去了"当成"被接受了"。

6.7 preamble:注入给 worker 的那段提示

buildDispatchPreamble(preamble.ts:47-145)组装 worker 看到的第一段文字。它的写法本身就是一个 prompt 工程样本(注释 :42-46):行为规则写在对应命令的上方注释里,而不是单独一段散文——因为 LLM 会锚定示例、略读尾部散文。

规则清单:

规则位置为什么
--body 必须是 3 句话执行摘要:71-73协调器先读 body,不够再翻 artifact
worker_done 恰好发一次,失败也要发:77-79禁止只在散文里暗示失败、禁止静默退出
payload 同时带 taskId 和 dispatchId:80-81让失败重试的迟到完成不能结算当前 dispatch
每 5 分钟一次心跳:89-93(常量 :40)与协调器 10 分钟阈值恰好 2 倍
禁止 AskUserQuestion:106-110那会开一个协调器看不见也答不了的本地 TUI 提示,会话永久挂死

CLI 名也是现场解析的(:51):dev 模式用 orca-dev(否则会连到生产实例的 socket),WSL pane 用 orca-ide

完成之后该干什么,按 worker 类型分两版(buildPostWorkerDoneInstructions,:147-182):prompt-returning agent 要回到空闲提示符、别退壳(因为重新派工是以终端输入形式送达的,退了就收不到);bare shell 则退出(它没有可复用的空闲提示符)。

6.8 协调器:一个克制的轮询循环

Coordinator.tick()(coordinator.ts:158-165)每 2 秒做六件事:

processMessages ─► processEscalations ─► processDecisionGates
│ │
▼ ▼
warnStaleDispatches ──► dispatchReadyTasks ──► checkConvergence

几个刻意的"不做":

  • 不自动杀慢 worker:warnStaleDispatches(:208-217)只打日志。注释(:207)给了理由——误杀一个慢但正确的 worker,代价高于放过一个挂死的。
  • 不自动解决门:processDecisionGates(:335-345)只把有未决门的任务重新压回 blocked。注释(:336)说自动解决"会让门作为审批检查点这件事本身失去意义"。
  • 不做任务分解:decompose(:186-196)发现没有任务就直接抛错,要求先用 task-create 建好 DAG。
  • 陈旧 base 时不失败、只跳过:worktree 落后基线超过 DISPATCH_STALE_THRESHOLD = 20(:36)个提交且没有 allow-stale-base: true 时,静默 return、任务留在 ready 下 tick 重试(:409-419)。注释(:392, :410)点破关键——这里若走 failDispatch,就会白白烧掉熔断器 3 次的预算。

熔断器本身在 failDispatch(db/dispatch-context/dispatch-completion.ts:76-158):失败计数达到 3 就变 circuit_broken,协调器据此把任务判死(coordinator.ts:218-220(经 applyEscalationToDispatch))。

6.9 联邦:跨机器的 worker

syncFederatedDispatch(federation-sync.ts:35-…)从远端 Orca 拉中继项,并且强制序号连续:

// 真实源码:src/main/runtime/orchestration/federation-sync.ts:69-74
if (item.dispatch_id !== dispatchId || item.sequence !== cursor + 1) {
throw new OrchestrationError('operation_unknown',
`Federated relay for ${dispatchId} is not contiguous after sequence ${cursor}.`)
}

配合 federation_relay_itemsPRIMARY KEY (dispatch_id, direction, sequence)UNIQUE (dispatch_id, direction, message_id)(db/schema/create-graph-tables-sql.ts:57-72),得到一条有序、去重、可续传的跨机管道。

6.10 消息呈现:格式即权限

formatMessageBanner(formatter.ts:53-98)在消息头上打权限标签,而不是在文档里解释:

标签含义
[LEGACY COMPATIBILITY]活的,只能用横幅里印出来的那条命令
[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]一次性至少一次的重放,要幂等处理
[LEGACY READ-ONLY]只读,没有 reply/ack
无标签当前语法

只有 current 才会附上 [Reply: orca orchestration reply --id … ](:88-94)。agent 能做什么,由它读到的横幅决定——这比在指南里写一段"什么时候不能回复"可靠得多。


7. 核心机制五:动作面 —— 浏览器与桌面

编排解决"谁做",动作面解决"怎么落到真实目标上"。

7.1 浏览器:ref 是契约

Orca 不让 agent 写 CSS 选择器,而是先出一份带编号的无障碍树快照,之后所有操作按编号走。

orca snapshot orca click @e12
│ │
▼ ▼
Accessibility.getFullAXTree refMap.get('@e12') → backendDOMNodeId
│ │
├─ walkTree:可交互角色 → @eN ├─ DOM.describeNode 探活
├─ 补一遍 CSS 扫描(cursor:pointer) │ 失败 → tryRecoverRef 按 role+name 重找
└─ 同名去重 → "Submit (2nd)" └─ 还不行 → browser_stale_ref,要求重新 snapshot
  • buildSnapshot(snapshot-engine.ts:27-137)先走 AX 树,只给 INTERACTIVE_ROLES(:44-61)发 ref。
  • 补扫 DOM:findCursorInteractiveElements(:357-…)用一次 Runtime.evaluate[onclick][tabindex]contenteditable,以及 getComputedStyle(el).cursor === 'pointer' 的 div/span/li/td/img/svg/label,上限 50 个。注释(:101-105)说明动机:现代 SPA 大量用无 ARIA 的 div 当按钮,AX 树看不见它们。
  • 跨源 iframe:各自有独立 CDP session,单独走一遍树,ref 记住自己的 sessionId(:112-143)。
  • 同名消歧:三个都叫 "Submit" 的按钮,第 2、3 个显示成 Submit (2nd),refMap 里记 nth(:149-181)。

nth 这个字段在失效恢复时才显出价值。resolveRef(cdp-bridge.ts:1343-1396)先探活,失败则 tryRecoverRef(:1503-1534)按 role+name 重新查 AX 树,并优先取第 nth 个匹配再回退到全部候选(:1519-1520)。真的找不到才抛 browser_stale_ref,并附上"跑 orca snapshot 拿新 ref"的指示。

导航检测是另一层保险:非 iframe 的 ref 在使用前会比对 Page.getNavigationHistory 的当前项(:1366-1377),页面变了就直接作废快照。

两套后端:AgentBrowserBridge(agent-browser-bridge.ts:571)通过外部 agent-browser 二进制驱动(路径解析见 resolveAgentBrowserBinary,:180-213:打包资源 → node_modules → PATH 兜底);CdpBridge(cdp-bridge.ts:88)直接用 Electron debugger 讲 CDP。二者共用 BrowserManager 注册的 WebContents,因为 BrowserBackend(browser-backend.ts:16-24)已经把"渲染进程 webview"和"headless 离屏 WebContents"的差异收敛掉了。

命令按 tab 串行化(enqueueCommand / processQueue,cdp-bridge.ts:1681-1728),导航后 invalidateRefMap(:1670-1679)清空快照。错误码也被特意归一:agent-browser 返回的泛化报错会被 classifyErrorCode(agent-browser-bridge.ts:259-265)映射成 browser_stale_ref,这样 agent 能识别"该重新 snapshot 了"。

Design Mode 抓取是另一条路:buildGuestOverlayScript(grab-guest-script.ts:16-29)生成五段自包含 JS(arm / awaitClick / finalize / extractHover / teardown),注入到 guest 页面的页面世界(guest 没有 preload,没有 Node)。防劫持写得很直白(:35-46):arm 时无条件先 teardown,因为恶意页面可以预先定义一个假的 window.__orcaGrab.extractPayload。所有字段都有预算上限(:48-60),防止一个巨型 DOM 把 payload 撑爆。

7.2 桌面:三平台一个协议

orca computer … 走的是 sidecar 子进程。协议朴素到位:父进程 process.send 一个 {id, method, params},sidecar 回 {id, ok, result|error}(sidecar-entry.ts:22-38)。dispatch(:40-95)把 14 个方法分成三类:

类别方法
观察capabilities / listApps / listWindows / getAppState
指点click / performSecondaryAction / scroll / drag
输入typeText / pressKey / hotkey / pasteText / setValue

Provider 是两级降级(computer-provider-lifecycle.ts:30-49):

darwin? ──是──► macOS 14+ 且找得到原生 helper? ──是──► MacOSNativeProviderClient(Swift,Unix socket)
│ │否 native/computer-use-macos
│否 ▼
└──────────────► DesktopScriptProviderClient
├─ win32 → powershell.exe native/computer-use-windows/runtime.ps1
└─ linux → python3 native/computer-use-linux/runtime.py

判定条件见 shouldUseMacOSNativeProvider(macos-native-provider-availability.ts:4-10),脚本路径解析见 resolveDesktopScriptProviderPath(desktop-script-provider-paths.ts:16-39,支持环境变量覆盖 + 打包/开发双路径)。

两条传输都设了明确超时:macOS 侧循环重连 helper 的 socket 直到 deadline(macos-native-provider-socket.ts:4-32);脚本侧 30 秒超时,先 SIGTERM、1 秒后 SIGKILL(desktop-script-provider-bridge.ts:5-6, 58-74),注释说明"原生自动化可能卡死在平台 API 里"。

给 agent 的输出是文本树 + 可选截图(renderSnapshot,desktop-script-snapshot-rendering.ts:5-…;CLI 渲染 computer-format.ts:23-55),元素本体缓存在内部、不塞进每次快照(:30-31)。Windows/Linux 可能缩放截图以控 IPC 体积,但窗口坐标保持未缩放(:12-13)——动作用的是后者。


8. 巧妙之处(可借鉴的技术)

  • 把易变内容从可复制物里删掉。 技能卡片只留路由信息,正文由二进制现场吐(skill-stubs/skills.ts:340-348)。任何"会被复制到用户磁盘且不会自动更新"的产物都适用这一招。
  • 别名与迁移清单只进不出。 GUIDE_ALIASES / STUB_TOPICS(generate-bundled-skill-guides.mjs:23-48)是兼容性账本,不是配置。
  • 错误里带可执行的下一步。 契约不匹配时返回 ['skills','get','orchestration','--full'](orchestration-rpc-contract.ts:9-14),让 agent 能自愈。
  • 判定只覆盖"命令真会写的那部分"。 eligibleSkillUpdateNames(skill-freshness-eligibility.ts:19-53)拒绝让无关副本参与授权判断。
  • 不信子进程的 stdout,信重扫磁盘。 SkillUpdateRunner.settle(skill-update-run.ts:196-199)。
  • 身份不是 handle,是 pane;而且只信 runtime 现场观测的那个。 hasLifecycleAuthority(lifecycle-reconciliation.ts:16-28)+ orchestration.ts:394-395
  • 结算是带 CAS 的事务。 条件 UPDATE 的 changes !== 1 即回滚(db/dispatch-context/worker-report-settlement.ts:112-122),幂等重复上报返回 duplicate: true
  • 拒绝理由是枚举而不是字符串。 WorkerReportSettlement(types.ts:25-36)让调用方能分支处理。
  • prompt 规则贴在示例上方。 preamble 的注释布局(preamble.ts:42-46)是有意为之的 LLM 可读性设计。
  • 可恢复失败不烧熔断预算。 陈旧 base 时静默 return 而非 failDispatch(coordinator-task-dispatch.ts:78-88)。
  • ref 记 nth,失效时按序数重定位。 snapshot-engine.ts:98-130 + cdp-bridge.ts:1519-1520
  • 注入页面世界前先无条件拆除旧状态。 防止页面预置假的抓取函数(grab-guest-script.ts:35-46)。
  • 启动成本被量化并被治理。 153/199 个 eager 模块的注释(index.ts:33-37)与全量懒加载 handler 组。

9. 边界与局限

边界表现依据
协调器不会做任务分解没有预建任务就直接抛错coordinator.ts:146-156
协调器不会自动杀挂死 worker只打一条 warningcoordinator.ts:162(经 warnStaleDispatches,coordinator-task-dispatch.ts:17)
协调器不会自动解决决策门只把任务压回 blockeddb/decision-gates/decision-gate-store.ts:70-80
allow-stale-base 用正则匹配任意行写在代码围栏里也会命中,fail-opencoordinator.ts:37
每 tick 只新建一个终端避免一次性爆开一堆coordinator.ts:248-254
skills install/update 只作用于本机检测到 ORCA_CLI_CWD 转发环境就拒绝handlers/skills.ts:280-287
真实安装不支持 --json它转发 npx 的非 JSON 流式输出handlers/skills.ts:305-313
检测不到任何 agent 就不装否则 skills add -y 会给约 75 个 agent 建目录handlers/skills.ts:219-227
全局技能更新只碰 canonical + 别名独立副本、项目技能、插件缓存一律不动skill-installation-topology.ts:34-49
技能扫描有硬上限单文件 256 KB、单包 200 文件discovery.ts:25-26
CSS 补扫最多 50 个元素长页面会漏掉靠后的伪按钮snapshot-engine.ts:390, 394
--full 目前等同普通版还没有 guide 带附加参考文档generate-bundled-skill-guides.mjs:126
桌面动作 30 秒硬超时超时后 SIGTERM→SIGKILLdesktop-script-provider-bridge.ts:5, 60-74
逐请求拦截尚未支持只能按 URL 模式路由agent-browser-bridge.ts:1965(TODO)
编排属于实验特性需在 Settings > Experimental 打开skill-guides/orchestration.md:51

10. 与本组其它章的关系


11. 代码地图(导航索引)

主题文件路径符号名
技能 stub 正文(真源)skill-stubs/orchestration.md—(纯 Markdown)
技能完整指南(真源)skill-guides/orchestration.md—(纯 Markdown)
stub 投影 + 内嵌表生成config/scripts/generate-bundled-skill-guides.mjscomposeStubProjectionserializeEmbeddedModulebuildArtifactsverifyArtifacts
迁移/别名账本config/scripts/generate-bundled-skill-guides.mjsSTUB_TOPICSGUIDE_ALIASESassertAliasContract
内嵌指南表(生成物)src/cli/bundled-skill-guides.tsBUNDLED_SKILL_GUIDES
skills 子命令src/cli/handlers/skills.tsSKILL_HANDLERSrequireTopiccreateSkillMutationHandlerresolveInstallAgentKeys
skills 命令规格src/cli/specs/skills.tsSKILL_COMMAND_SPECS
技能目录清单src/main/skills/skill-discovery-sources.tsbuildSkillDiscoverySourcessourceKindForSkillstablePathId
技能扫描与去重src/main/skills/discovery.tsdiscoverSkillsfindSkillFilesscanRoot
Claude 插件技能源src/main/skills/claude-plugin-skill-sources.tsgetClaudePluginMetadataPathsisProjectInstallApplicable
安装形态分类src/main/skills/skill-installation-topology.tsclassifyHomeSkillTopologyskillTopologyPriorityhasSymlinkedAncestor
可更新判定src/main/skills/skill-freshness-eligibility.tseligibleSkillUpdateNames
更新执行器src/main/skills/skill-update-run.tsSkillUpdateRunnerCANCEL_RELEASE_TIMEOUT_MS
CLI 入口src/cli/index.tsmainloadRuntimeClientClassresolveInvocationCwd
参数解析与校验src/cli/args.tsparseArgsnormalizeCommandPositionalsvalidateCommandAndFlagseffectiveAllowedFlags
命令路由表src/cli/handler-group-manifest.tsHANDLER_GROUPS
浏览器命令组src/cli/browser-handler-groups.tsBROWSER_HANDLER_GROUPS
懒加载分发src/cli/dispatch.tsdispatchbuildRoutesHANDLER_COMMAND_KEYS
编排 CLIsrc/cli/handlers/orchestration.tsORCHESTRATION_HANDLERSresolveOrchestrationTerminalHandlecallMutation
编排状态类型src/main/runtime/orchestration/types.tsMESSAGE_TYPESTaskStatusDispatchStatusGateStatusWorkerReportSettlementWorkerDispatchState
编排持久层src/main/runtime/orchestration/db.tsOrchestrationDbSCHEMA_VERSIONcreateTaskpromoteReadyTaskssettleWorkerReportfailDispatchgetStaleDispatches
生命周期授权src/main/runtime/orchestration/lifecycle-reconciliation.tsreconcileLifecycleMessagehasLifecycleAuthoritysuppressEarlierHeartbeats
派工提示词src/main/runtime/orchestration/preamble.tsbuildDispatchPreamblebuildPostWorkerDoneInstructionsbuildDriftSection
协调循环src/main/runtime/orchestration/coordinator.tsCoordinatorDISPATCH_STALE_THRESHOLDparseAllowStaleBaseFromSpec
消息呈现src/main/runtime/orchestration/formatter.tsformatMessageBannerformatMessagesForInjection
跨机中继src/main/runtime/orchestration/federation-sync.tssyncFederatedDispatch
编排 RPCsrc/main/runtime/rpc/methods/orchestration.tsorchestration.sendorchestration.checkorchestration.ask
worker 组合启动src/main/runtime/rpc/methods/orchestration-worker-topology.tsrequireWorkerAuthorityWorkerEffectWorkerSetupReceipt
RPC 契约src/shared/orchestration-rpc-contract.tsisOrchestrationMutationorchestrationSkillRecoveryDataORCHESTRATION_SKILL_COMMAND_ARGS
无障碍快照src/main/browser/snapshot-engine.tsbuildSnapshotfindCursorInteractiveElementsINTERACTIVE_ROLES
CDP 动作与 ref 恢复src/main/browser/cdp-bridge.tsCdpBridgeresolveReftryRecoverRefinvalidateRefMap
外部浏览器桥src/main/browser/agent-browser-bridge.tsAgentBrowserBridgeresolveAgentBrowserBinaryclassifyErrorCode
后端抽象src/main/browser/browser-backend.tsBrowserBackend
Design Mode 抓取src/main/browser/grab-guest-script.tsbuildGuestOverlayScript
computer sidecarsrc/main/computer/sidecar-entry.tsdispatchhandleMessage
provider 选择src/main/computer/computer-provider-lifecycle.tsComputerProviderLifecycle
macOS 原生可用性src/main/computer/macos-native-provider-availability.tsshouldUseMacOSNativeProvider
macOS socket 连接src/main/computer/macos-native-provider-socket.tsconnectMacOSProviderSocket
脚本 provider 桥src/main/computer/desktop-script-provider-bridge.tsexecBridge
脚本路径解析src/main/computer/desktop-script-provider-paths.tsresolveDesktopScriptProviderPath
原生实现native/computer-use-macos/native/computer-use-windows/runtime.ps1native/computer-use-linux/runtime.py