跳到主要内容

主机运行时:orchestrator 如何监管三个 sidecar

30 秒导读: openwork 这个 CLI(包名 openwork-orchestrator)是 OpenWork 在你机器上的监工。 你敲一句 openwork start,它就负责把三个各自独立的进程——opencode(AI 编码引擎)、 openwork-server(鉴权代理)、opencode-router(消息连接器)——找到二进制、分配端口、生成凭据、 按依赖顺序拉起、逐个做健康探测,并盯着它们的生死。整章讲的就是这套"拉起 + 监管"逻辑, 几乎全部集中在一个文件:apps/orchestrator/src/cli.ts(约 8761 行)。


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

  • 一句话定义: orchestrator 是一个进程监工 CLI。它自己不做 AI、不做鉴权、不做消息路由, 只负责"把该跑的三个进程一键拉起来,并在它们出事时收拾残局"。

  • 解决什么问题 / 给谁用: OpenWork 的能力被拆成了三个独立的可执行文件(sidecar,即"边车进程")。 手动启动要开三个终端、记三个端口、配三套凭据、还要保证启动顺序对。orchestrator 把这些琐事 收进一条命令,给两类调用者用:

    • 桌面外壳(见 01-desktop-shell.md):Electron 外壳把 orchestrator 当子进程拉起。
    • 命令行 / 远程 worker:开发者或托管环境直接 openwork start
  • 它监管的三个 sidecar 是谁:

    sidecar白话职责本章讲它的哪部分
    opencodeAI 编码引擎(真正干活的模型循环 + 工具)怎么被定位、拉起、健康探测
    openwork-server鉴权代理 + 文件系统 API(见 03-openwork-server.md)怎么拿到 token、反向控制通道
    opencode-router消息连接器(Telegram / Slack / WhatsApp,见 06-remote-cloud.md)怎么按配置决定启不启、可容错
  • 用起来什么样: 最小一条命令,orchestrator 打印出三份地址后进入监工循环:

$ openwork start --workspace ~/my-project
OpenWork orchestrator running
Run ID: 5f3c...-...
Workspace: /Users/me/my-project
OpenCode: http://127.0.0.1:52713
OpenWork server: http://127.0.0.1:8787
OpenWork collaborator token: issued (withheld from stdout)
# ...(前台 TUI 或日志流保持运行,Ctrl-C 关闭三个进程)
  • 一句话直觉/类比: 把它想成一个微型 init / supervisor(像 systemdforeman), 只不过它专门伺候 OpenWork 那三个进程,还额外管好了端口、凭据、二进制下载和沙箱容器。

本节不碰底层代码。记住一件事:orchestrator 是监工,不是干活的——干活的是被它拉起的三个 sidecar。


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

2.1 一个 CLI,六种角色

main() 拿第一个位置参数当子命令来分发(缺省是 start):

真实实现: apps/orchestrator/src/cli.ts:8705 main() —— 逐个 if 派发到对应的 run* 函数。

子命令干什么入口函数
start / serve拉起并监管三个 sidecar(本章主线)runStart (cli.ts:6851)
daemon多工作区路由守护进程(单 opencode + 多 workspace)runDaemonCommand (cli.ts:5755)
workspace增删/切换/查询工作区(打到 daemon 的 HTTP API)runWorkspaceCommand (cli.ts:5791)
instance释放某工作区实例runInstanceCommand (cli.ts:5861)
status探 opencode / openwork-server 健康runStatus (cli.ts:6782)
approvals / files审批与文件会话的薄命令行封装runApprovals / runFiles

serve 只是 start 的一个变体:它先 args.flags.set("tui", false) 再走 runStart,即"不开 TUI、只流式打日志"(cli.ts:8724)。

本章聚焦 start(三-sidecar 监工),daemon 的边界在 §3.7 单独说清楚,不重复。

2.2 一次 openwork start(主机模式)的全景

主机模式(非沙箱,--sandbox none,默认)是理解 orchestrator 的主线。从左到右是时间顺序, 命中健康门才走下一步:

openwork start

├─ 解析参数 · 选空闲端口 · 造托管凭据 · 定位三个二进制
├─ 起「控制面 HTTP」(给 openwork-server 反向调用,做热升级)

├─▶ ① 拉起 opencode ───────等 /health────▶ 健康
├─▶ ② 拉起 opencode-router ─等 /health────▶ 健康(失败可跳过)
└─▶ ③ 拉起 openwork-server ─等 /health────▶ 健康 + 一致性校验

监听三者 exit / error;任一意外死 → 全体收摊退出

怎么读这张图: 三个 sidecar 不是并发拉起的,而是按依赖串起来——opencode 最先(它是被代理的后端), openwork-server 最后(它要代理到已经健康的 opencode)。这条顺序在源码里有注释点明 (cli.ts:8486 "In host mode, opencodeRouter is started before openwork-server")。

2.3 部件一句话职责

部件干什么在哪(cli.ts 符号)
参数解析--flag value / --no-x / -h 解成 MapparseArgs (:345)
二进制定位决定用 bundled / downloaded / external 哪个二进制resolveOpencodeBin 等 (:2610)
端口分配探端口能不能绑,不能就随机取空闲口resolvePort / findFreePort (:1157)
凭据生成给 opencode 造随机 basic auth、给 server 造 tokengenerateManagedOpencodeCredentials (:1232)
进程拉起统一 spawn,注入环境变量与 OTEL 属性startOpencode / startOpenworkServer / startOpenCodeRouter (:3785/:3872/:3996)
健康门轮询 /health 直到 2xx 或超时waitForHealthy / waitForOpencodeHealthy (:3485/:3578)
监工exit/error 回调,收尾handleExit / shutdown (:7867/:7607)
控制面本地 HTTP,让 server 反向触发运行时热升级runStartcontrolServer (:7907)

2.4 和相邻两章的职责边界

  • 对上:桌面外壳(01)。 桌面壳可以选择"直连 fallback"自己拉 sidecar, 也可以把本 orchestrator 当子进程拉起。orchestrator 只负责主机侧的进程编排,不含任何 UI; 它把地址/凭据通过 stdout(--json)或 TUI 交回给壳。
  • 对下:openwork-server(03)。 orchestrator 只负责把它拉起来并盯活; 真正的鉴权、请求代理、文件系统 API 全在 server 内部。orchestrator 通过环境变量把 token、opencode 后端地址、控制面地址交给它(§3.5)。

3. 核心原理(逐个机制,由浅入深)

3.1 参数解析:一个极简的手写解析器

它要解决的小问题: 没有引第三方 argparse 库,自己把 argv 折成 {positionals, flags}

思路: 三条规则——--no-x 记成 x=false;--k=v--k v 记成 k=v; 后面没值的 --k 记成 k=true;不带 -- 的是位置参数。

真实实现: parseArgs (cli.ts:345)。之后所有读取都走 readFlag / readBool / readNumber 这几个带"flag → 环境变量 → 默认值"三级回退的取值器(如 readBool, cli.ts:420)。这让每个配置项 天然支持"命令行 > 环境变量 > 内置默认"的优先级。

3.2 sidecar 定位:bundled / downloaded / external 三源

它要解决的小问题: 同一个 orchestrator,既要能在打包好的桌面 app 里跑(二进制随包内置), 也要能在开发机上跑(用本地 openwork-server npm 包),还要能在全新环境里跑(从 GitHub Release 下载)。

思路: 每个 sidecar 有一个"来源偏好" BinarySourcePreference = auto | bundled | downloaded | external (cli.ts:214),auto 时按**"内置 → 下载 → 外部"**的优先级逐级尝试。

三种来源:

来源含义关键动作
bundled随打包二进制内置,靠 versions.json 清单定位resolveBundledBinary (:2219) + readVersionManifest (:1557)
downloaded从 GitHub Release 下载对应平台资产,校验 sha256downloadSidecarBinary (:2004)
external用系统里已装的(--allow-external 才允许)resolveExternal(各 resolve*Bin 内)

原理演示: auto 的回退次序(以 openwork-server 为例):

// 示意,非源码:resolveOpenworkServerBin 里 auto 分支的骨架
const bundled = await resolveBundledBinary(manifest, "openwork-server"); // 先找内置
if (bundled && !(allowExternal && explicit)) return bundled;
const downloaded = await downloadSidecarBinary({ name, sidecar }); // 再试下载
if (downloaded) return downloaded;
return resolveExternal(); // 最后退到外部
// 重点看:三源是"能用就停"的瀑布,不是并行

真实实现: resolveOpenworkServerBin (cli.ts:2495)、resolveOpencodeBin (cli.ts:2610)、 resolveOpenCodeRouterBin (cli.ts:2715)。三者结构几乎一致,差别在 opencode 额外有一条 "直接下 opencode 官方发布包"的兜底(resolveOpencodeDownload)。

关键细节: 下载路径按 <dir>/<version>/<target>/<asset> 缓存;若本地已存在且 sha256 对得上就直接复用, 对不上就删掉重下(cli.ts:2034)。target 由平台+架构推出(resolveSidecarTarget, cli.ts:1767), 沙箱模式下强制成 Linux 目标(resolveSandboxSidecarTarget, cli.ts:1786),因为容器里跑的是 Linux 二进制。

3.3 端口与凭据:随机口 + 托管 basic auth + 连接 URL

它要解决的小问题: 三个进程要各占一个本地端口,不能撞车;opencode 必须加锁(basic auth), 否则本机任何进程都能连它执行工具;远程访问还要给出一个"能连的 URL"。

端口: resolvePort(preferred, host, fallback)——首选口能 canBind 就用,否则回退口,再否则 让内核随机分配一个空闲口(findFreePortlisten(0))。

真实实现: canBind (cli.ts:1126)、findFreePort (cli.ts:1139)、resolvePort (cli.ts:1157)。 opencode / openwork-server / opencode-router-health / 控制面 各自走一遍 resolvePort

opencode 凭据(托管、强制、不可自定义):

  • 每次启动生成一对 512 字符的随机十六进制 username/password (generateManagedOpencodeCredentials, cli.ts:1232;MANAGED_OPENCODE_CREDENTIAL_LENGTH = 512, cli.ts:133)。
  • basic auth 始终开启:--no-opencode-auth 会直接抛错(assertManagedOpencodeAuth, cli.ts:1296)。
  • opencode 的绑定 host 强制 loopback:非 127.0.0.1/localhost/::1 一律拒绝 (resolveManagedOpencodeHost, cli.ts:1310)。这保证 opencode 只在本机、且必须带凭据才能连。

openwork-server 的三种 token:

token用途怎么来
collaborator token(openworkToken)客户端/SDK 调 server API 的 Bearerflag/env 或随机 UUID (cli.ts:7075)
host token(openworkHostToken)审批、host-only API 的 X-OpenWork-Host-Tokenflag/env 或随机 UUID (cli.ts:7079)
owner token远程客户端回答权限弹窗用起来后调 server API 现签 (issueOpenworkOwnerToken, cli.ts:5142)

连接 URL: 只有开了 --remote-access(或显式 --openwork-host 0.0.0.0)才把 server 绑到 0.0.0.0 (resolveOpenworkRemoteAccess, cli.ts:1335),此时用 resolveConnectUrl (cli.ts:1193) 找一个 LAN IP 或 .local mDNS 名拼出可分享的 URL(resolveLanIp, cli.ts:1180)。默认只在 127.0.0.1encodeBasicAuth (cli.ts:1215) 负责把 opencode 凭据编成 Basic <base64> 头。

3.4 拉起顺序与健康门(主机模式)

它要解决的小问题: 后拉起的进程依赖先拉起的进程已经"活着且健康",顺序错了会连锁失败。

依赖顺序: opencode → opencode-router → openwork-server。每步都先 spawn、挂 exit/error 回调、 再阻塞等健康:

① startOpencode → waitForOpencodeHealthy(SDK client) cli.ts:8188 / 8236
② startOpenCodeRouter → waitForOpenCodeRouterHealthy(/health) cli.ts:8252 / 8314
③ startOpenworkServer → waitForHealthy(/health) + verify cli.ts:8353 / 8404 / 8408

统一的拉起动作: 三个 start* 函数长得一样——都用 spawnProcess(cli.ts:1810,它在 spawn 外面 包了 buildSpawnEnv 补 PATH 和 Windows 隐藏窗口),stdio["ignore","pipe","pipe"] 抓 stdout/stderr, 再用 prefixStream 给每行日志打上服务名前缀,并注入 OTEL_RESOURCE_ATTRIBUTES 让三个进程的遥测能区分。

真实实现(opencode 的启动参数与环境): startOpencode (cli.ts:3785) 组装 serve --hostname <loopback> --port <p> --cors ...,并把托管凭据、config 目录、热重载开关 通过环境变量传进去(OPENCODE_SERVER_USERNAME 等,cli.ts:3832)。

健康探测的两条兜底:

  • waitForOpencodeHealthy (cli.ts:3578) 先看 /healthhealthy 字段;若探针坏了,退而求其次—— 只要一次 path.get() 成功就当"可用"(有些运行时 /health 假死但 API 已可用)。
  • opencode-router可容错的:除非加了 --opencode-router-required,它启动失败只会 warn 并继续, 不会拖垮整体(cli.ts:8329);运行中若它自己退出,也只降级不收摊(cli.ts:8279)。

3.5 监工:任一死则收摊 + 反向控制面

它要解决的小问题: sidecar 意外崩了要干净退出(别留僵尸端口);同时又要能在不重启 orchestrator 的前提下,把某个 sidecar 升级到新版本。

"任一死则全体收摊": 每个子进程都挂了 exit/error。非关停期一旦收到意外退出:

真实实现: handleExit (cli.ts:7867) 打日志、把 TUI 状态标 stopped,然后 shutdown().then(() => process.exit(code ?? 1))——shutdown (cli.ts:7607) 会清定时器、关控制面、 stopChild 逐个先 SIGTERMSIGKILL(cli.ts:3759)、并做沙箱清理。handleSpawnError (cli.ts:7890) 同理但退出码固定 1。

反向控制面(orchestrator 被 server 回调): runStart 起了一个本地 HTTP 控制服务器 (cli.ts:7907),用一个随机 controlToken 做 Bearer 鉴权,暴露两个端点:

方法 · 路径作用
GET /runtime/versions回一份运行时快照(三个 sidecar 的源/版本/是否有升级)
POST /runtime/upgrade触发对指定 sidecar 的原地热升级

它把 OPENWORK_CONTROL_BASE_URL / OPENWORK_CONTROL_TOKEN 通过环境变量交给 openwork-server (startOpenworkServer, cli.ts:3968),于是 server 端可以反过来命令 orchestrator 升级 sidecar。

热升级怎么做: performRuntimeUpgrade (cli.ts:7522) 对 external 源的包先 npm i -g 装新版, 再重新 resolve*Bin,然后逐个原地重启(restartOpencode / restartOpenCodeRouter / restartOpenworkServer)。重启期间把服务名塞进 restartingServices 集合,让 handleExit 识别出"这是我主动杀的,不是崩了"从而不触发收摊(cli.ts:7873)。沙箱模式不支持热升级——直接抛错。

3.6 沙箱模式:把三个 sidecar 塞进一个容器

它要解决的小问题: 让 agent 在隔离环境里改代码,不碰宿主机文件系统。

思路的关键差异: 主机模式下 orchestrator 监管三个进程;沙箱模式下它只 spawn 一个 docker run (或 Apple container run)进程,三个 sidecar 由容器内的一段 entrypoint 脚本在容器里启动。 orchestrator 只盯这一个容器进程,健康探测走 openwork-server 的代理路径。

主机模式: orchestrator ──spawn──▶ opencode
──spawn──▶ opencode-router
──spawn──▶ openwork-server
沙箱模式: orchestrator ──spawn──▶ docker run(容器)
└─ entrypoint.sh 在容器内起三个 sidecar

真实实现:

  • stageSandboxRuntime (cli.ts:4201) 把三个二进制 copyFile 到 persist 目录并 chmod +x
  • writeSandboxEntrypoint (cli.ts:4253) 生成一段 shell:设 HOME/XDG 变量、校验必需的密钥环境变量存在 (${OPENWORK_TOKEN:?...})、后台起 opencode 与 opencode-router、exec 起 openwork-server。
  • startDockerSandbox (cli.ts:4390) 拼 docker run --rm -p 127.0.0.1:<port>:8787 -v <workspace>:/workspace ..., 只对外发布 openwork-server 一个端口——沙箱里 opencode/router 只经 server 代理访问,不单独暴露口。

额外挂载受白名单闸门: --sandbox-mount hostPath:subpath[:ro|rw] 不是随便挂的。 resolveSandboxExtraMounts (cli.ts:876) 要求存在一份 allowlist 文件,逐条校验:解析 spec (parseSandboxMountSpec, cli.ts:816)→ 真实路径 → 命中黑名单模式(password/secret/token 等)则拒 → 必须落在某个 allowed root 下(loadSandboxAllowlist, cli.ts:768)。这是一道防"手滑把 ~/.ssh 挂进容器"的闸。

backend 选择: --sandbox autoresolveSandboxMode (cli.ts:1851) 在 Apple silicon 上优先探 container,否则探 docker(resolveDockerCommand 在多个常见路径里找 docker 可执行文件,cli.ts:1100)。

3.7 daemon 模式:多工作区路由(边界说明)

openwork daemon run另一条产品线,不要和 start 混淆。start 是"一个工作区、三个 sidecar、前台监管"; daemon 是"一个 opencode 常驻 + 一个 HTTP 路由服务 + 管理多个工作区"。

真实实现: runRouterDaemon (cli.ts:5884) 起一个 HTTP 服务,把状态存进 router-state.json (loadRouterState/saveRouterState),ensureRouterDaemon (cli.ts:5701) 负责按需 detached 拉起它 (spawnRouterDaemon, cli.ts:5598),workspace/instance 子命令就是打到这个 daemon 的 HTTP 客户端 (requestRouter, cli.ts:5733)。它和 start 的三-sidecar 监管共享二进制定位/端口/凭据这套底层工具, 但不是本章讲的"监管三个 sidecar"那套流程。深入的多工作区语义见其它章,本章不展开。

3.8 worker 活跃心跳(托管环境)

在托管 worker(如 Daytona)里,orchestrator 会周期性上报"最近有没有人在用",让平台决定能否休眠。

真实实现: resolveWorkerActivityHeartbeatConfig (cli.ts:1396) 只有当一串 DEN_* 环境变量都齐 (feature 开、provider 是 daytona、有 workerId/url/token)才启用;postWorkerActivityHeartbeat (cli.ts:1438) 拉 opencode 的会话列表,取最近一次会话活动时间,判断是否落在活跃窗口内再 POST 上报。 这条线服务于远程与云,本章只点出它挂在监工循环的定时器上。


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

  • "能用就停"的三源瀑布 + sha256 缓存复用。 二进制定位不写死一种来源,而是 bundled→downloaded→external 逐级降级,下载结果按 版本/平台 缓存并校验哈希,重启零成本复用(cli.ts:2034)。
  • 托管凭据不给自定义。 opencode 的 basic auth 强制开启、host 强制 loopback、密码 512 位随机——把"本机 AI 引擎被裸连执行工具"这类脚枪从设计上焊死(cli.ts:1296/:1310)。
  • 反向控制面做零重启热升级。 orchestrator 反手给 server 一个带 token 的本地 HTTP,让 server 能命令它 原地换掉某个 sidecar;靠 restartingServices 集合区分"主动重启"与"崩溃",避免误触发全体收摊 (cli.ts:7522/:7873)。
  • 沙箱把 N 进程监管压成 1 进程监管。 容器内用一段自动生成的 entrypoint 脚本管三个进程的生死与 trap, orchestrator 只盯一个 docker run,并只对外开一个端口(cli.ts:4253/:4462)。
  • 挂载白名单 + 黑名单双闸。 额外沙箱挂载必须在 allowlist root 内、且不命中 secret/token 等模式, 防止敏感目录被误挂进容器(cli.ts:876)。

5. 边界与局限(诚实)

  • 不是自愈 supervisor。 主机模式下任一 sidecar 意外退出,orchestrator 的动作是收摊退出而非重启 (handleExit, cli.ts:7867);只有 opencode-router 在非 required 时是例外(降级继续)。真正的重启只发生在 显式热升级里。
  • 热升级只限主机模式。 沙箱模式下 restartOpencode 等一律抛 "Runtime upgrade is not supported while sandbox mode is enabled"(cli.ts:7366)。
  • 沙箱平台受限。 Apple container backend 只支持 macOS + arm64,不满足会抛错(cli.ts:7182)。
  • external 版本校验偏宽松。 opencode 用 external 二进制且版本不匹配时只 warn 不阻断 (verifyOpencodeVersion, cli.ts:4771),避免上游抢跑发版时卡住桌面用户——代价是可能跑在非预期版本上。
  • daemon 与 start 是两套流程。 本章只覆盖 start 的三-sidecar 监管;daemon 的多工作区语义不在此展开。

6. 与兄弟章的关系

主题去哪
谁把 orchestrator 当子进程拉起、以及"直连 fallback"01-desktop-shell.md
被拉起的 openwork-server 内部怎么鉴权/代理/暴露文件 API03-openwork-server.md
opencode 之上的技能/MCP/插件扩展04-extensibility.md
单一 React UI 如何连上 server 渲染会话05-frontend.md
opencode-router 的消息连接器与托管 worker 心跳06-remote-cloud.md

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

全部符号位于 apps/orchestrator/src/cli.ts(sourceCommit b71c06b)。行号可能随上游漂移,请优先用符号名 grep

主题文件路径符号名
命令分发入口apps/orchestrator/src/cli.tsmain
主机模式监管主流程apps/orchestrator/src/cli.tsrunStart
参数解析apps/orchestrator/src/cli.tsparseArgs
统一进程拉起apps/orchestrator/src/cli.tsspawnProcess
拉起 opencodeapps/orchestrator/src/cli.tsstartOpencode
拉起 openwork-serverapps/orchestrator/src/cli.tsstartOpenworkServer
拉起 opencode-routerapps/orchestrator/src/cli.tsstartOpenCodeRouter
二进制定位(server/opencode/router)apps/orchestrator/src/cli.tsresolveOpenworkServerBin / resolveOpencodeBin / resolveOpenCodeRouterBin
下载 sidecar + 校验 sha256apps/orchestrator/src/cli.tsdownloadSidecarBinary
读内置版本清单apps/orchestrator/src/cli.tsreadVersionManifest
端口分配apps/orchestrator/src/cli.tsresolvePort / findFreePort / canBind
托管 opencode 凭据apps/orchestrator/src/cli.tsgenerateManagedOpencodeCredentials / assertManagedOpencodeAuth
loopback / 远程访问约束apps/orchestrator/src/cli.tsresolveManagedOpencodeHost / resolveOpenworkRemoteAccess / resolveConnectUrl
健康门apps/orchestrator/src/cli.tswaitForHealthy / waitForOpencodeHealthy / waitForOpenCodeRouterHealthy
监工:退出与收尾apps/orchestrator/src/cli.tshandleExit / handleSpawnError / shutdown / stopChild
反向控制面 + 热升级apps/orchestrator/src/cli.tsperformRuntimeUpgrade / getRuntimeSnapshot(controlServerrunStart 内)
沙箱:暂存 / 入口脚本 / docker 启动apps/orchestrator/src/cli.tsstageSandboxRuntime / writeSandboxEntrypoint / startDockerSandbox
沙箱挂载白名单apps/orchestrator/src/cli.tsresolveSandboxExtraMounts / loadSandboxAllowlist / parseSandboxMountSpec
backend 选择 / docker 定位apps/orchestrator/src/cli.tsresolveSandboxMode / resolveDockerCommand
router-enabled 判定apps/orchestrator/src/cli.tsresolveOpencodeRouterEnabled
daemon 多工作区模式apps/orchestrator/src/cli.tsrunRouterDaemon / ensureRouterDaemon / spawnRouterDaemon
worker 活跃心跳apps/orchestrator/src/cli.tsresolveWorkerActivityHeartbeatConfig / postWorkerActivityHeartbeat