主机运行时: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(像
systemd或foreman), 只不过它专门伺候 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 解成 Map | parseArgs (:345) |
| 二进制定位 | 决定用 bundled / downloaded / external 哪个二进制 | resolveOpencodeBin 等 (:2610) |
| 端口分配 | 探端口能不能绑,不能就随机取空闲口 | resolvePort / findFreePort (:1157) |
| 凭据生成 | 给 opencode 造随机 basic auth、给 server 造 token | generateManagedOpencodeCredentials (:1232) |
| 进程拉起 | 统一 spawn,注入环境变量与 OTEL 属性 | startOpencode / startOpenworkServer / startOpenCodeRouter (:3785/:3872/:3996) |
| 健康门 | 轮询 /health 直到 2xx 或超时 | waitForHealthy / waitForOpencodeHealthy (:3485/:3578) |
| 监工 | 挂 exit/error 回调,收尾 | handleExit / shutdown (:7867/:7607) |
| 控制面 | 本地 HTTP,让 server 反向触发运行时热升级 | runStart 内 controlServer (: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 下载对应平台资产,校验 sha256 | downloadSidecarBinary (: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 就用,否则回退口,再否则
让内核随机分配一个空闲口(findFreePort 用 listen(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 的 Bearer | flag/env 或随机 UUID (cli.ts:7075) |
host token(openworkHostToken) | 审批、host-only API 的 X-OpenWork-Host-Token | flag/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.1。
encodeBasicAuth (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) 先看/health的healthy字段;若探针坏了,退而求其次—— 只要一次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 逐个先 SIGTERM 后 SIGKILL(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 auto 时 resolveSandboxMode (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 上报。
这条线服务于远程与云,本章只点出它挂在监工循环的定时器上。