跳到主要内容

数据截至 (上游 commit a9c304f343f1)

主机运行时:openwork-server 如何托管 OpenCode 引擎

30 秒导读: OpenWork 的"运行时"指把 OpenCode(agent 引擎)这个子进程拉起来、管好凭据、盯住生死的那一层。历史上它是一个独立的监工 CLI(openwork-orchestrator,一次监管三个 sidecar)——该 CLI 已在上游被整体移除。现在的形态收敛为一条线:openwork-server 进程自己托管引擎——它 spawn opencode serve、发随机凭据、解析 stdout 就绪信号、把引擎登记进注册表;引擎的配置热更新走"蓝绿滚动池"。桌面侧的 Electron 主进程只做一件事:用一条串行队列把"启停服务器"的请求排成一列。本章讲清这套新形态,并顺带记录旧 orchestrator 的退场痕迹。


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

  • 一句话定义: "主机运行时" = openwork-server 内部那个引擎托管器——负责把 OpenCode 从一个外部二进制变成一个"带凭据、有登记、可安全回收"的受管子进程。

  • 解决什么问题 / 给谁用: OpenCode 是独立进程,裸跑在 127.0.0.1 上等于把"能改你文件、能跑命令"的 HTTP 口子暴露给本机任何程序。必须有人负责:

    • 给它上锁(随机 basic auth 凭据);
    • 知道它什么时候就绪(拿到真实监听地址);
    • 死了/换了要能被发现(注册表 + 孤儿回收);
    • 配置变更时不打断正在跑的会话(蓝绿池)。
  • 它服务于两类宿主:

    • 桌面外壳(01-desktop-shell.md):Electron 主进程把 openwork-server 以进程内模块方式起起来(见 §3.1),引擎由这个服务器托管;
    • CLI / 云 worker:openwork-server 命令直接跑(apps/server/src/cli.ts),Den 托管 worker 就是这么起的(见 §3.6)。
  • 用起来什么样: 桌面用户点开工作区,日志里出现的一行就是托管成功的信号:

Managed OpenCode listening on http://127.0.0.1:52713
OpenWork server listening on http://127.0.0.1:8787
  • 一句话直觉/类比: 旧设计是"请了一个独立监工(orchestrator)看着三个工人";新设计是让工头(openwork-server)自己带着最重的那个工人(OpenCode)——少一个进程、少一层 RPC,凭据不出工头的手心。

本节不碰底层代码。记住一件事:引擎的生死管理已经内聚进 openwork-server,不再有独立监工进程。


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

2.1 一条命令的引导顺序

openwork-server 的 CLI 入口(apps/server/src/cli.ts)是一条自顶向下的引导脚本,顺序有讲究:

openwork-server (apps/server/src/cli.ts)
├─ 解析参数 / 配置 · 建 logger (cli.ts:37, config.ts 的 parseCliArgs)
├─ startServer(config) ← 先绑 HTTP 端口 (cli.ts:58)
│ └─ 为什么先绑:端口被占时 serve-node 会退到
│ OS 随机口,引擎的 OPENWORK_SERVER_URL
│ 必须指向"真实绑上的那个口" (cli.ts:52-57 注释)
├─ startWorkerActivityHeartbeat (cli.ts:61, 见 §3.5)
└─ OPENWORK_MANAGE_OPENCODE=1 ? (cli.ts:63)
├─ reapOrphanEngineInstances 清死引擎 (cli.ts:68)
├─ writeOpenworkRuntimeConfigFile 写引擎配置 (cli.ts:72)
├─ createManagedOpencodeServer ★spawn 引擎 (cli.ts:96)
├─ registerEngineInstance 登记注册表 (cli.ts:125)
├─ createEnginePoolForConfig 蓝绿池 (cli.ts:138)
└─ syncAllWorkspacesRuntimeMcpToEngine (cli.ts:154)

怎么读这张图: 从上到下是启动顺序;"先绑端口再 spawn 引擎"是刻意的——注释明说引擎 spawn 时注入的 OPENWORK_SERVER_URL 必须指向实际绑上的端口,而不是请求的端口(apps/server/src/cli.ts:52-57)。

2.2 部件一句话职责

部件干什么在哪
CLI 入口引导顺序编排(见上图)apps/server/src/cli.ts(219 行,薄壳)
createManagedOpencodeServerspawn opencode serve + 随机凭据 + stdout 就绪解析apps/server/src/managed-opencode.ts:236
createManagedProcessClose关停协议:SIGTERM → 1s → SIGKILL → 500msapps/server/src/managed-opencode.ts:41
引擎注册表engine-instances.json,登记/回收孤儿引擎apps/server/src/engine-registry.ts
EnginePool蓝绿滚动:忙时换配置不起飞新会话apps/server/src/engine-pool.ts:297
就绪延迟判断池关闭时"忙则推迟 dispose"apps/server/src/engine-reload-defer.ts:17
worker 心跳Daytona 托管环境上报活跃度apps/server/src/worker-activity-heartbeat.ts:155
桌面运行时管理器串行化启停、token/端口持久化、内嵌服务器apps/desktop/electron/runtime.mjs:1333(createRuntimeManager)

2.3 和相邻两章的职责边界

  • 对上:桌面外壳(01)。 外壳不 spawn 引擎;它 import() 一个打包好的 embedded 服务器模块、在 Electron 主进程内起 HTTP 服务(startEmbeddedServer,apps/server/src/embedded.ts:69),引擎托管逻辑全部复用本章这套。
  • 对下:openwork-server(03)。 本章只讲"引擎子进程怎么被托管";请求代理、审批、文件 API 仍是第 3 章的内容。

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

3.1 桌面侧:一条串行队列 + 进程内服务器

它要解决的小问题: 桌面启动时,主进程的 bootRuntimeForSelectedWorkspace 和渲染进程的连接逻辑会并发engineStart。不串行的话,后一个调用的 prepareFreshRuntime 会把前一个刚绑好的服务器杀掉,再抢"黏性首选端口",撞进 EADDRINUSE

思路: createRuntimeManager(apps/desktop/electron/runtime.mjs:1333)用一条 promise 队列把所有生命周期操作排成一列——withRuntimeLifecycle(runtime.mjs:1361),注释原话点名了这个竞态;对外的 engineStart/engineStop/engineRestart/prepareFreshRuntime/dispose 全部经它包裹(runtime.mjs:2289-2294)。

engineStart(runtime.mjs:2066)还有一道复用捷径:已有健康的服务器、工作区/远程访问/蓝绿设置都没变,就直接返回现有快照(runtime.mjs:2091-2102)。

真正起服务器的是 startOpenworkServer(runtime.mjs:1849):它不 spawn 子进程,而是 import() 打包好的 embedded 模块并在Electron 主进程内起 HTTP 服务:

// apps/desktop/electron/runtime.mjs:1889(节选)— 进程内起服务器,不是 spawn
const { startEmbeddedServer } = await import(embeddedServerImportUrl(embeddedPath));
const handle = await startEmbeddedServer({
host, port: portSelection.port, corsOrigins: ["*"],
token: tokens.clientToken, hostToken: tokens.hostToken,
manageOpencode: options.manageOpencode === true, // ★ 由服务器去托管引擎
opencodeBin: managedOpencode?.path ?? undefined,
});
inProcessServer = handle;

manageOpencode: true 是关键——引擎的 spawn 与监管交给服务器内的本章机制(apps/server/src/embedded.ts:69startEmbeddedServer 会走和 CLI 同样的 managed-opencode 流程)。桌面侧顺手把服务器凭据存进 openwork-server-tokens.json(loadServerCredentials/persistServerOwnerToken,runtime.mjs:1426-1436),端口偏好存进 openwork-server-state.json(loadPortState,runtime.mjs:1411)——重启后端口与凭据保持"黏性"。

3.2 托管引擎:spawn + 随机凭据 + stdout 就绪

它要解决的小问题: OpenCode 没有现成的"托管模式"。要把它变成受管子进程,得回答三个问题:凭据从哪来?什么时候算就绪?端口撞车怎么办?

凭据:随机、强剥离。 createManagedOpencodeServer(apps/server/src/managed-opencode.ts:236)每次 spawn 都现造一对 randomSecret()(两个去横线的 UUID 拼接,managed-opencode.ts:93),经 OPENCODE_SERVER_USERNAME/PASSWORD 环境变量注入(managed-opencode.ts:159-160),命令行是 serve --hostname 127.0.0.1 --port <free> --cors *(managed-opencode.ts:154)。还有一条安全细节:引擎环境刻意删掉 OPENWORK_ENCRYPTION_KEY——引擎需要 provider 环境,但绝不能拿到能解密 OpenWork OAuth 凭据的钥匙(managed-opencode.ts:162-164 注释)。

就绪信号:看 stdout,不轮询。 旧 orchestrator 用 /health 轮询当健康门;新实现改为解析子进程 stdout 里的一行:

// apps/server/src/managed-opencode.ts:201-204(节选)
if (!line.startsWith("opencode server listening")) continue;
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match?.[1]) return fail(new Error(`Failed to parse OpenCode server URL from: ${line}`));

监听地址以 OpenCode 自己打印的为准,15 秒拿不到就超时(managed-opencode.ts:188);进程提前退出则等 close 事件(而非 exit)再判失败,好把诊断输出收全(managed-opencode.ts:211-213 注释)。

端口竞态:只重试一次、只重试这一种错。 自动探到的空闲口天然有竞态。createManagedOpencodeServer 只对"退出码 1 且输出含 EADDRINUSE"这一种失败换口重试一次(isRetryableAddressInUseExit,managed-opencode.ts:141-145;重试在 :241-248);显式指定端口或其它错误都不重试——注释明说"显式端口和其它 code-1 退出仍然可操作"。

关停协议。 createManagedProcessClose(managed-opencode.ts:41)实现 SIGTERM → 等 1 秒 → SIGKILL → 再等 500 毫秒 → 仍不死才抛错;close()closePromise ??= 保证幂等(重复调用共享同一次关停)。

3.3 引擎注册表:死服务器的引擎不变成孤儿

它要解决的小问题: 引擎是子进程,服务器是父进程;父进程崩了(没走 shutdown),子进程还活着、注册记录还在——下次启动看到一条"进程不知是谁的"记录,不敢动它。

思路: 每次成功 spawn,把 {pid, port, url, startedAt, role, ownerPid, authProbe…} 写进运行时存储目录的 engine-instances.json(engineRegistryFilePath,apps/server/src/engine-registry.ts:63-65;写入是 registerEngineInstance,:150)。下次启动先 reapOrphanEngineInstances(engine-registry.ts:262)——ownerPid 已死且引擎进程自己也探测不通的记录才清掉,best-effort、失败不阻塞启动(apps/server/src/cli.ts:68.catch(() => undefined))。

记录里的 authProbe 是一个预构造的 Basic 头(buildEngineAuthProbeHeader,engine-registry.ts:67):回收方不用碰用户名/密码本体,拿现成探针去敲门即可——凭据内容尽量少流动。

role 字段取 starting | primary | draining(engine-registry.ts:25),直接对应下一节的蓝绿池两代引擎。

3.4 蓝绿池:配置热更新不打断正在跑的会话

它要解决的小问题: OpenCode 只在建实例时读一部分配置(provider、plugin、agent、permission)。改了这类配置就得 dispose 重建——可 dispose 会中断所有在跑的会话

三种选择的取舍(文件头注释,apps/server/src/engine-pool.ts:2-19):

方案代价
立刻 dispose 重建在跑的会话全部中断
推迟到引擎空闲配置被长会话"劫持",迟迟不生效
蓝绿滚动(选定)短暂双进程、实现复杂

蓝绿的规则(EnginePool,engine-pool.ts:297):

  • 新配置到来时,spawn 一个待命引擎,新请求全部指向它;
  • 老引擎保持存活,只服务已在跑的会话,跑完即退——因为会话数据在 OpenCode 的共享 SQLite 里,只有"正在跑的那一次"绑定在进程上(engine-pool.ts:10-12);
  • 稳态永远只有一个引擎;排干期间最多两个;排干中途又来配置变更,会合并成一个待处理滚动(最新者赢),绝不堆进程(engine-pool.ts:14-16);
  • 该池默认关闭,仅当 ServerConfig.engineRollover 打开才启用(engine-pool.ts:18-19)。

池关闭时的兜底是 shouldDeferInPlaceEngineReload(apps/server/src/engine-reload-defer.ts:17):没有池就退回"忙则推迟"——注释点明 OpenCode 的实例缓存按目录隔离,只有目标目录会被这次 reload 打断,而蓝绿池不需要推迟,因为活会话留在排干代上(engine-reload-defer.ts:10-14)。

桌面侧把"是否开池"做成用户偏好:resolveEngineRolloverPreference(apps/desktop/electron/runtime.mjs:431)读传入值与持久值,engineStart 用它决定 manageOpencode 的池模式(runtime.mjs:2087-2090)。

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

它要解决的小问题: Den 云上的 worker 按"最近有没有人用"决定能否休眠省钱——得有人周期性上报。

思路: 心跳从旧 orchestrator 搬进了 openwork-server(startWorkerActivityHeartbeat,apps/server/src/worker-activity-heartbeat.ts:155;CLI 在 apps/server/src/cli.ts:61 启动它)。启用条件收得很紧(resolveWorkerActivityHeartbeatConfig,worker-activity-heartbeat.ts:56-64):DEN_ACTIVITY_HEARTBEAT_ENABLED 显式开、DEN_RUNTIME_PROVIDER=daytona、且 DEN_WORKER_ID / DEN_ACTIVITY_HEARTBEAT_URL / DEN_ACTIVITY_HEARTBEAT_TOKEN 三者齐全,缺一即静默关闭。上报内容按"最近一次会话活动是否落在活跃窗口"计算(buildWorkerActivityHeartbeatPayload,:94),POST 由 postWorkerActivityHeartbeat(:113)完成。这条线服务于远程与云

3.6 云 worker:同一套代码跑在托管机上

Den 的托管 worker 不再装 orchestrator——运行时根目录(ee/apps/den-worker-runtime/)由控制面装 openwork-server、按仓库 constants.json 钉住的版本在构建期 vendor 一份 opencode 二进制,再用 openwork-server 命令启动(ee/apps/den-worker-runtime/README.md)。也就是说:云上那台 worker 与你桌面跑的是同一个服务器入口、同一套引擎托管逻辑(本章 §3.2/§3.3),"local-first, cloud-ready" 落到部署层面就是这一条。心跳(§3.5)让平台能休眠闲置 worker。


4. 旧 orchestrator 的退场痕迹(考古)

本章旧版讲的是 apps/orchestrator/src/cli.ts(约 8700 行)那套"下载三 sidecar、健康门、反向控制面、Docker 沙箱"。上游已整体移除该 app,但留了几处可考据的痕迹:

  • apps/desktop/electron/nuke.mjs:40 定义 LEGACY_ORCHESTRATOR_DIR_NAME = "openwork-orchestrator"——"核弹级"重置会把旧 orchestrator 的数据目录一并抹掉,证明它曾长期存在于用户磁盘上;
  • 旧的三 token(client/host/owner)体系没有消失,而是随托管化搬进了服务器与桌面 token store(第 3 章 + 本章 §3.1 的 loadOrCreateWorkspaceTokens);
  • 旧的"沙箱模式"(Docker/Apple container 三 sidecar 入容器)不复存在;runtime.mjs 里残留的 docker 探测(resolveDockerCandidates,apps/desktop/electron/runtime.mjs:1613)服务于诊断/清理而非沙箱编排。

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

  • 就绪信号用"当事人自述"。 不轮询 /health,而是解析子进程自己打印的 opencode server listening on <url>(managed-opencode.ts:198-206)——地址由监听者亲口报出,天然消除"端口是请求的还是实际绑的"这类歧义。
  • 重试窗口开得极窄。 只对 EADDRINUSE + 退出码 1 重试一次、且仅当端口是自动探的(managed-opencode.ts:241-248)——把"重试能安全修复的失败"和"重试无意义的失败"在类型上分开。
  • 注册表带"探针"不带"钥匙"。 authProbe 是预构造好的 Basic 头(engine-registry.ts:67),回收路径不需要接触凭据明文。
  • 蓝绿池的三条硬上限。 稳态一个、排干最多两个、变更合并最新者赢(engine-pool.ts:14-16)——用三条不变量把"热更新"钉死在可控成本内。
  • 串行队列防自杀。 桌面把所有启停操作排进一条 promise 队列(runtime.mjs:1350withRuntimeLifecycle),从根上消除并发 start 把对方服务器杀掉的竞态。

6. 边界与局限(诚实)

  • 引擎托管只认 stdout 就绪行。 若上游 OpenCode 改了启动日志措辞,opencode server listening 匹配会失效,托管启动直接报"Failed to parse OpenCode server URL"(managed-opencode.ts:203)——这是一份对上游日志格式的隐式契约
  • 蓝绿池默认关闭。 默认路径仍是"忙则推迟 reload"(engine-reload-defer.ts:17),长会话期间配置变更可能长期不生效。
  • 孤儿回收是 best-effort。 启动期 reapOrphanEngineInstances 失败会被吞掉(cli.ts:68),不会阻塞服务——代价是极少数情况下注册表会留下死记录。
  • SIGKILL 后仍不退才抛错。 关停协议在 SIGKILL 后只再等 500ms(managed-opencode.ts:82);真遇到不可杀的进程只能报错上抛。
  • 本章不覆盖远程引擎。 桌面也能连"外部 OpenCode 服务器"(opencodeBaseUrl 指定时完全不 spawn,apps/server/src/cli.ts:63),那条路径没有托管、注册表与池——它们只对 managed 引擎生效。

7. 与兄弟章的关系

主题去哪
谁调用 engineStart、窗口与 IPC 外壳01-desktop-shell.md
托管引擎的服务器内部怎么鉴权/代理/暴露文件 API03-openwork-server.md
引擎之上的技能/MCP/插件扩展04-extensibility.md
UI 如何连上服务器渲染会话05-frontend.md
Den 托管 worker、心跳的消费方、桌面↔云同步06-remote-cloud.md

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

按符号名可 grep,比行号抗漂移。行号 as-of sourceCommit a9c304f

主题文件路径符号名
CLI 引导脚本apps/server/src/cli.ts顶层 await 流(startServer/createManagedOpencodeServer/registerEngineInstance)
spawn 托管引擎apps/server/src/managed-opencode.tscreateManagedOpencodeServerstartManagedOpencodeServerManagedOpencodeExitError
关停协议apps/server/src/managed-opencode.tscreateManagedProcessClose(SIGTERM→SIGKILL 升级)
随机凭据apps/server/src/managed-opencode.tsrandomSecretSECRET_ENV_PATTERN(env 脱敏快照)
引擎注册表apps/server/src/engine-registry.tsregisterEngineInstancereapOrphanEngineInstancesbuildEngineAuthProbeHeaderengineRegistryFilePath
蓝绿池apps/server/src/engine-pool.tsEnginePoolcreateEnginePoolForConfigcomputeEngineConfigFingerprintEnginePoolSnapshot
忙则推迟 reloadapps/server/src/engine-reload-defer.tsshouldDeferInPlaceEngineReload
worker 活跃心跳apps/server/src/worker-activity-heartbeat.tsresolveWorkerActivityHeartbeatConfigpostWorkerActivityHeartbeatstartWorkerActivityHeartbeat
内嵌服务器入口(桌面用)apps/server/src/embedded.tsstartEmbeddedServerEmbeddedServerHandle
桌面运行时管理器apps/desktop/electron/runtime.mjscreateRuntimeManagerwithRuntimeLifecycleengineStartstartOpenworkServerresolveEngineRolloverPreference
token/端口持久化apps/desktop/electron/runtime.mjsloadOrCreateWorkspaceTokensloadPortState
旧 orchestrator 清理痕迹apps/desktop/electron/nuke.mjsLEGACY_ORCHESTRATOR_DIR_NAME
云 worker 运行时ee/apps/den-worker-runtime/README(装 openwork-server + vendored opencode)