跳到主要内容

数据截至 (上游 commit c76af90d88f4)

Runner 守护进程:从手机上凭空开一个新会话

30 秒导读: 前面几章讲的都是"一个会话已经存在之后怎么远程操作它"。这一章讲机器这一层: 一个常驻后台进程(runner)把整台机器注册到 hub,于是你在手机上点"新建会话"时, 那台在家里开着的 Mac 才会真的 fork 出一个新的 agent 进程。没有它,手机只能操作存量会话。


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

  • 一句话定义: runner 是 HAPI CLI 在你机器上跑的一个后台常驻进程,代表"这台机器"在线, 并接受来自 hub 的三条机器级指令:开会话、停会话、停 runner。
  • 解决什么问题: 你在通勤路上想起来"那个重构还没跑完",打开手机 Web 想新开一个会话—— 但家里那台 Mac 上此刻一个 HAPI 会话都没有。谁来接这条指令?
  • 一句话直觉: 把 runner 想成机器的前台接待。会话是一个个访客(来了又走), 接待常年在岗,负责"来客登记 + 按上级电话叫人进场"。

为什么必须是一个独立的常驻进程

关键在于 HAPI 的会话进程是 detached(脱离父进程) 启动的,它随时可能结束:

happyProcess = spawnHappyCLI(args, {
cwd: spawnDirectory,
detached: true, // Sessions stay alive when runner stops
...

cli/src/runner/run.ts:665-673(spawnSession 内)。注释说得很直白:runner 停了会话还活着。 反过来同样成立——会话退光了,机器不能因此从 hub 上消失。所以"机器在线"这件事, 必须由一个和会话生命周期解耦的进程来持有。

用起来什么样

$ hapi runner start
Runner started successfully

$ hapi runner status # 走 doctor 的进程发现
$ hapi runner list # 列出这个 runner 知道的活会话
$ hapi runner stop # 停 runner,已有会话不受影响

子命令分发在 cli/src/commands/runner.ts:92-196(runnerCommand.run)。注意 startstart-sync 是两个东西:

子命令谁跑干什么
runner start你的终端(前台)先停掉旧 runner,再 spawnHappyCLI(['runner','start-sync'], { detached: true }) 拉起后台进程,轮询状态文件确认起来了就退出
runner start-sync后台进程自己await startRunner({ workspaceRoots }),这才是真正的守护循环

依据:cli/src/commands/runner.ts:130-170(start 分支)、cli/src/commands/runner.ts:172-176(start-sync 分支)。


2. 顶层全景(这条链大概怎么转)

怎么读这张图: 从左到右是一次"手机上新建会话"的请求方向;最右边那个新进程起来后, 会回头用一个本地 HTTP 回调把自己的 sessionId 报给 runner(虚线)。

手机/Web hapi-hub 本机 runner 进程 新会话进程
┌─────────┐ ┌───────────┐ ┌────────────────────┐ ┌──────────────┐
│ 点"新建"│──HTTP─▶│ RpcGateway│──Socket─▶│ ApiMachineClient │ │ hapi claude │
│ │ │ 按 machineId │ 收 machineId: │─▶───│ --hapi- │
│ │ │ 路由到那台机器 │ spawn-happy-session│detached│ starting- │
└─────────┘ └───────────┘ │ │ │ │ mode remote │
│ ▼ │ └──────┬───────┘
│ spawnSession() │ │
│ ┌──────────────┐ │◀ ─ ─ ─ ─ ─ ┘
│ │本地控制服务 │ │ POST /session-started
│ │127.0.0.1:随机 │ │ (带 sessionId + hostPid)
│ └──────────────┘ │
└────────────────────┘

部件职责一览:

部件干什么文件
startRunner守护进程主体:装信号、抢锁、起服务、连 hub、跑心跳cli/src/runner/run.ts:118
本地控制服务只绑 127.0.0.1 随机端口的 Fastify,供本机 CLI 与新会话回调cli/src/runner/controlServer.ts:14
ApiMachineClient与 hub 的 Socket.IO 长连、机器心跳、机器级 RPC 落地cli/src/api/apiMachine.ts:116
spawnSession真正拉起新 agent 进程的那段(校验目录 / 注入凭据 / 等回调)cli/src/runner/run.ts:497
RpcGateway.spawnSessionhub 侧:把 Web 的 REST 请求翻译成机器级 RPChub/src/sync/rpcGateway.ts:168
createWorktree给"worktree 会话"开一个独立 git 工作树cli/src/runner/worktree.ts:101
buildSessionMetadata新会话自报家门用的元数据(host / machineId / hostPid…)cli/src/agent/sessionFactory.ts:80

会话内部的本地/远程双模循环不在本章——见 02 本地/远程双模接管; 权限审批见 03 反向 RPC 与权限审批; hub 侧状态与版本号见 04 Hub 的状态与同步


3. 启动流程:startRunner 到底做了哪 7 件事

入口 cli/src/runner/run.ts:118(startRunner)。按代码顺序拆开:

① 装 shutdown promise + 四类信号源
② 认交接(HAPI_RUNNER_HANDOFF_FROM_PID)→ 决定要不要走 ③
③ 版本比对:isRunnerRunningCurrentlyInstalledHappyVersion()
④ 抢独占锁:acquireRunnerLock()
⑤ 起本地控制服务(拿到随机端口)→ 写 runner.state.json
⑥ 注册机器到 hub → 建 Socket.IO 长连 → 挂 RPC handler
⑦ 起 60s 健康检查循环,然后 await shutdown promise

3.1 shutdown 是一个 promise,四类信号源

runner 不用一堆散落的 process.exit(),而是先造一个"等待关机"的 promise, 把 resolve 存成 requestShutdown,任何想关机的地方调它并带上关机来源:

let requestShutdown: (source: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception', errorMessage?: string) => void;

cli/src/runner/run.ts:128。四类来源分别从哪来:

source触发者代码位置
os-signalSIGINT / SIGTERM(Windows 另加 SIGBREAK)run.ts:149-164
exceptionuncaughtException / unhandledRejectionrun.ts:166-178
hapi-cli本地 POST /stop(即 hapi runner stop)run.ts:1049controlServer.ts:174-192
hapi-apphub 转发的 stop-runner RPC(手机上按的)run.ts:1151apiMachine.ts:466-469

这个 source 最后会被写进 hub 上的 runnerState.shutdownSource(run.ts:1434-1439,cleanupAndShutdown), 所以 Web 上能看出"这台机器是被人 Ctrl+C 了,还是崩了"。

一个容易忽略的兜底: requestShutdown 里同时挂了一个 1 秒后 process.exit(1) 的定时器 (run.ts:134-141),注释写的是"万一启动阶段坏了、优雅关机走不通就强退"。副作用是优雅清理只有约 1.1 秒预算, 超时就会以退出码 1 结束。

3.2 版本比对:靠文件 mtime,不是靠版本号字符串

isRunnerRunningCurrentlyInstalledHappyVersion()(cli/src/runner/controlClient.ts:165)判"在跑的 runner 是不是当前安装的这份代码"。 它优先比二进制/包文件的修改时间,而不是 package.json 里的版本号:

export function getInstalledCliMtimeMs(): number | undefined {
if (isBunCompiled()) {
try { return statSync(process.execPath).mtimeMs; } catch { return undefined; }
}
const packageJsonPath = join(projectPath(), 'package.json');
...

cli/src/runner/controlClient.ts:17-36。为什么?因为 npm i -g hapi@latest 升级到同版本号的重新发布、 或者本地 bun build --compile 重编,版本号字符串都可能没变,但代码变了。mtime 变了就该换 runner。

同一个函数还额外比身份(apiUrl / machineId / CLI_API_TOKEN 的哈希 / extra headers 哈希), 不匹配也算"不是同一个 runner"(controlClient.ts:221-233,isRunnerStateCompatibleWithIdentity)。 换 hub、换 token 都会触发换进程。

3.3 独占锁:wx 打开 + PID 自愈

const fileHandle = await open(configuration.runnerLockFile, 'wx');
await fileHandle.writeFile(String(process.pid));

cli/src/persistence.ts:215-217(acquireRunnerLock)。wx = O_CREAT|O_EXCL, 文件存在就抛 EEXIST,这是文件系统级的原子互斥。

妙在失败后的自愈:拿不到锁时读锁文件里的 PID,如果那个进程已经不在了,就删掉陈旧锁重试 (cli/src/persistence.ts:220-229)。所以机器断电后重启不会被一个残留的锁文件永久卡住。

重试参数按场景区分:

场景maxAttempts × 步进总等待为什么
普通启动5 × 200ms~1s拿不到就认为"已有 runner 在跑",直接退
自更新交接60 × 500ms~30s父 runner 是异步释放锁的,子必须等得起

依据 cli/src/runner/run.ts:246-252

3.4 状态文件与本地控制服务

状态文件路径 ~/.hapi/runner.state.json,锁文件是它加 .lock 后缀(cli/src/configuration.ts:98-99)。 写入的字段类型见 cli/src/persistence.ts:35-67(RunnerLocallyPersistedState),关键几项:

字段作用
pid / httpPort本机其它 CLI 进程靠它找到 runner 并发 HTTP
startedWithCliMtimeMs版本漂移判断的基准(见 §4,故意不可变)
startedWithArgv自更新时用来原样重放启动参数(含 --workspace-root)
startedWithCliApiTokenHash只存哈希,绝不落盘明文 token
startedWithVersionHandoffDisabledHAPI_DISABLE_VERSION_HANDOFF=1 的意图持久化

控制服务是一个 Fastify,只绑回环地址、端口由系统分配:

app.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {

cli/src/runner/controlServer.ts:194。五个端点全是 POST:

端点谁调干什么行号
/session-started新起的会话进程自己上报 sessionId + metadata(含 hostPid)controlServer.ts:38
/listhapi runner list列出已跟踪且已确认 sessionId 的会话controlServer.ts:60
/spawn-session本机 CLI本地开会话(409 表示需要目录创建审批)controlServer.ts:107
/stop-session本机 CLI停某个会话,返回三态controlServer.ts:87
/stophapi runner stop先回 {status:'stopping'},50ms 后才真关controlServer.ts:174-192

这里没有任何鉴权头——安全边界完全靠"只绑 127.0.0.1"这一条。

3.5 连 hub:两条心跳不要混淆

注册走 REST getOrCreateMachine,外面裹了 60 次重试(run.ts:1121-1141,withRetry + isRetryableConnectionError), 所以开机时网络还没就绪也能扛过去。然后建 Socket.IO 长连:

this.socket = io(`${configuration.apiUrl}/cli`, {
transports: ['websocket'],
auth: {
token: this.token,
clientType: 'machine-scoped' as const,
machineId: this.machine.id
},
...

cli/src/api/apiMachine.ts:539-551(connect)。clientType: 'machine-scoped' 是这条连接和"会话连接"的分水岭。

两条心跳,周期和职责完全不同:

心跳周期面向谁干什么代码
machine-alive20shub让 Web 上那台机器显示为在线,附带 collectMachineHealth()apiMachine.ts:652-680
本地健康检查60s(HAPI_RUNNER_HEARTBEAT_INTERVAL 可调)自己剪枝死会话 / 查版本漂移 / 查状态文件归属 / 写 lastHeartbeatrun.ts:1212-1421

另外还有一条不算心跳的上行:apiMachine.updateRunnerState(...)。它走的是和会话 agentState 完全同构的 版本号乐观并发通道(hub/src/socket/handlers/cli/machineHandlers.ts:97-140),本章后面的 shutdownSource(§3.1)和 lastSpawnError(§5.6)都是经它落到 hub 上的——通道本身见 04 Hub 的状态与同步

本地健康检查里有一条很短但很重要的自杀逻辑:

const runnerState = await readRunnerState();
if (runnerState && runnerState.pid !== process.pid) {
// 别人把状态文件抢走了,我该自杀
requestShutdown('exception', '...');
}

cli/src/runner/run.ts:1389-1393。这保证同一台机器同一时刻只有一个 runner 在对 hub 说话


4. 自更新的交接协议:一个被修好的"机器彻底离线"事故

这是本章最值得学的一段。run.ts:192-213 那段长注释本身就是很好的教学素材。

4.1 旧协议为什么会把机器搞离线

老做法很直觉:父 runner 发现 CLI 二进制 mtime 变了 → 拉起一个新 runner → 新 runner 上来先 stopRunner() 干掉旧的 → 自己接管。问题出在顺序:

旧协议(有洞):
子 runner 启动
└─▶ stopRunner() ──HTTP /stop──▶ 父 runner
├─ 删掉 runner.state.json
├─ 释放锁
└─ 退出 ← 此刻机器已无 runner
└─▶ 抢锁 / 认证 / 写自己的状态 …… 任何一步失败
✗ 机器彻底离线,且没人会重新拉起它

注释里点名了这个洞(run.ts:198-204):/stop 让父亲在子进程完成自我提交之前就删状态、放锁、退出; 子进程只要在"stopRunner 到 writeRunnerState"之间的任意一点栽了(锁竞争、认证失败),机器就没了 runner。 更糟的是 systemd 的 Restart=on-failure干净退出不生效,不会救回来。

4.2 新协议:父亲不许先死

新协议(父亲最后才退):

父 runner(心跳发现 mtime 变了)

├─① spawnHappyCLI(startedWithArgv, { env: HAPI_RUNNER_HANDOFF_FROM_PID=<父pid> })
├─② releaseRunnerLock() ← 故意先放锁,否则死锁:子拿不到锁就写不了状态
├─③ waitForRunnerHandoff(父pid, 30s) ← 轮询 state.pid 变成另一个"活着的" pid
│ ├─ 成功 ──▶ clearInterval + process.exit(0)
│ └─ 超时 ──▶ 重新抢锁(60×500ms),抢到就继续活着,5 分钟后再试
│ 抢不到 ──▶ 干净退出(锁在别人手里,硬撑更危险)

子 runner
├─ 读到 HAPI_RUNNER_HANDOFF_FROM_PID 且 state.pid == 它 且进程活着 → isAuthorizedHandoff
├─ 跳过 stopRunner()、跳过"版本相同就退出"的短路
└─ 用 30s 的长窗口抢锁 → 写自己的 state.pid(这一步就是给父亲的"我起来了"信号)

对应代码:子进程认交接 run.ts:214-225;跳过分支 run.ts:227-239;父进程整段在 run.ts:1278-1384;等待函数 waitForRunnerHandoffcli/src/runner/controlClient.ts:248-270

4.3 三个不显眼但关键的修正

startedWithCliMtimeMs 必须不可变。 早期版本用"把基准 mtime 刷新成当前值"来做重试节流, 结果这个值会被心跳写进 runner.state.json,让 isRunnerRunningCurrentlyInstalledHappyVersion() 误报旧 runner 是最新的。现在改成用时间戳门控节流:

let nextHandoffAttemptAt = 0;
const HANDOFF_RETRY_BACKOFF_MS = 5 * 60_000;

cli/src/runner/run.ts:1222-1223,基准值的不可变理由写在 run.ts:1053-1060

② 重放 argv 不能用 process.argv.slice(2) 编译成单文件二进制后 argv 形状是 [hapi, runner, start-sync, ...],slice(2) 得到 ['start-sync', ...],重放出来变成 hapi start-sync——一个不认识的顶层命令,会 fallback 去跑 Claude 而不是 runner。改用规范化的 getCliArgs(),外面再加一道防御:

const startedWithArgv = rawStartedWithArgv[0] === 'runner'
? rawStartedWithArgv
: ['runner', 'start-sync'];

cli/src/runner/run.ts:1077-1080

③ 给自管理运维一个退出开关。 用 systemd / tmux / 自建重编流水线的人,源码 mtime 会因为 和"升级"无关的原因变动。HAPI_DISABLE_VERSION_HANDOFF=1 跳过整段自重启,但保留心跳的其余职责 (run.ts:1274-1277)。而且这个意图会被持久化进状态文件,因为环境变量往往只设在 service 单元里、 不在运维的交互 shell 里——否则一句 hapi runner start 就会在重编到一半时把线上 runner 杀掉 (run.ts:1085,读取端 controlClient.ts:200-204)。


5. 远程开会话全链路

现在把"手机上点一下"这条链一段段走完。

Web(或 Telegram Mini App 里打开的同一个 Web)
│ POST /api/machines/:id/spawn

hub: routes/machines.ts → SyncEngine.spawnSession → RpcGateway.spawnSession
│ emit('rpc-request', { method: "<machineId>:spawn-happy-session", params })

CLI: RpcHandlerManager → apiMachine.ts 的 SpawnHappySession handler
│ ① 工作区越界拦截

run.ts: spawnSession()
│ ② 目录三态校验 ③ worktree 分支 ④ 按 agent 注入凭据
│ ⑤ buildCliArgs ⑥ detached spawn ⑦ 等回调(默认 15s)

新进程 bootstrapSession → notifyRunnerSessionStarted → POST /session-started

└─▶ awaiter resolve → 一路原路返回 sessionId

5.1 hub 侧:REST 翻译成机器级 RPC

Telegram bot 本身不发 spawn,它只给你一个打开 Mini App 的按钮(hub/src/telegram/bot.ts:114), 所以入口统一是 Web 的 POST /api/machines/:id/spawn (前端 web/src/api/client.ts:817spawnSession,后端路由声明在 hub/src/web/routes/machines.ts:67)。

hub 内部一路透传到 RpcGateway.spawnSession(hub/src/sync/rpcGateway.ts:168), 最终变成一条带 machineId 前缀的 RPC 方法名:

private async machineRpc(
machineId: string,
method: string,
params: unknown,
timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS
): Promise<unknown> {
return await this.rpcCall(`${machineId}:${method}`, params, timeoutMs)
}

hub/src/sync/rpcGateway.ts:487-494。方法名常量是 spawn-happy-session(shared/src/rpcMethods.ts:8), CLI 侧注册时同样拼 ${scopePrefix}:${method}(cli/src/api/rpc/RpcHandlerManager.ts:90)—— 两边靠"machineId 命名空间"对上号,一台机器一条路由。DEFAULT_RPC_TIMEOUT_MS 是 30 秒 (rpcGateway.ts:40,详见 03 反向 RPC 与权限审批)。

5.2 CLI 侧第一关:工作区越界拦截

handler 在 cli/src/api/apiMachine.ts:411(setRPCHandlers 里注册 RPC_METHODS.SpawnHappySession)。 它做的第一件事不是开进程,而是拦截路径越界:

const resolvedDirectory = await this.resolveForWorkspaceCheck(directory)
if (!this.isWithinWorkspaceRoots(resolvedDirectory)) {
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' }
}

cli/src/api/apiMachine.ts:418-421。两个函数各有讲究:

  • resolveForWorkspaceCheck(apiMachine.ts:390):先 realpath 解符号链接, 否则 /safe/out -> /etc 这种链接能用纯字面量比较绕出去。路径还不存在时(新建目录场景) 会一路向上找到最近的存在祖先、对它 realpath、再把缺失的尾巴拼回来。
  • isWithinWorkspaceRoots(apiMachine.ts:371):用 relative() 判包含, rel.. 开头或是绝对路径就算越界。没配 --workspace-root 时直接返回 true(apiMachine.ts:372)—— 这是遗留模式的兼容,等于不设防。

同一套守卫也复用在 list-directory、opencode/grok 模型探测、Codex 会话枚举上 (apiMachine.ts:172-360),因为那几个也会在指定 cwd 下起子进程或读文件。

5.3 目录校验的三态

run.ts:513-536validateWorkspaceDirectory(cli/src/runner/validateWorkspaceDirectory.ts:39),返回三态:

返回含义runner 的动作
ok { created: false }目录已存在直接开
ok { created: true }已批准,刚 mkdir -p 出来开,并在结果里带一句"我们替你建了这个文件夹"
requestApproval路径不存在且未获批准requestToApproveDirectoryCreation,Web 弹审批
error各种硬失败回人话错误

这个模块是从 run.ts 里抽出来重写的,起因是一个很具体的坑:老代码用 fs.access + fs.mkdir({recursive:true}), 遇到悬空符号链接(链接在、目标被 git worktree remove 删了)时,access 跟随链接抛 ENOENT, 接着 mkdir 又因为路径上有个非目录条目抛 EEXIST: file already exists——用户看到的是一句完全误导的报错。 新实现改用 fs.lstat 不跟随链接地分辨"悬空链接 / 真的不存在 / 有个普通文件占位"三种情况 (详见 validateWorkspaceDirectory.ts:25-38 的注释和 handleSymlink:96)。

errno 到人话的映射表在 describeMkdirError(validateWorkspaceDirectory.ts:165):EACCES / ENOTDIR / ENOSPC / EROFS / EEXIST。 还有一处安全细节:报错文案故意不拼成可复制的 shell 命令(比如 rm '<path>'), 因为路径里含单引号会破坏引号配对,把一句诊断变成误删/注入向量(validateWorkspaceDirectory.ts:110-116)。

5.4 按 agent 注入凭据

hub 可以在 spawn 请求里带 token。怎么把这个 token 交给下游 agent,各家不同:

agent注入方式代码
codexmkdtemp(tmpdir()/hapi-codex-*) → 写 auth.json → 设 CODEX_HOMErun.ts:614-625
claude(默认)设环境变量 CLAUDE_CODE_OAUTH_TOKENrun.ts:626-630
grok / opencode / 其它不注入,靠各自 CLI 自己的登录态代码里没有对应分支(run.ts:611-631 只有上面两支)

Codex 这一支是"文件型凭据"的通用解法:不去污染用户真实的 ~/.codex,而是造一个一次性 HOME 目录, 只放这一次会话要用的 auth.json

worktree 会话还会额外注入五个 HAPI_WORKTREE_* 环境变量(run.ts:633-642),下游用它填元数据(见 §7)。

5.5 拼命令行:buildCliArgs

buildCliArgs(导出在 cli/src/runner/run.ts:1464)把 RPC 参数翻译成一行 CLI 参数。最要紧的一行是:

const startingMode = options.startingMode || 'remote';
args.push('--hapi-starting-mode', startingMode, '--started-by', 'runner');

cli/src/runner/run.ts:1510-1511。这两个标记决定了新进程的性格:

  • --started-by runner:会话元数据里 startedFromRunner: true,同时让某些 agent 跳过本地版本检查 (例如 cli/src/commands/codex.ts:115-120)。
  • --hapi-starting-mode remote:会话一起来就处于远程模式,不去抢终端(startingMode 可被调用方覆盖,但 runner 场景默认 remote)。 终端起的会话默认是 local(cli/src/claude/runClaude.ts:265)。双模细节见 02 本地/远程双模接管

resume 参数各家语法不同,这里统一抹平(run.ts:1492-1503):

agent续会话参数
codexresume <id>(子命令,不是 flag)
pi--session-id <id>
其余(claude / cursor / grok / kimi / opencode)--resume <id>

另外 codex / cursor / pi / opencode / agy(以及 Claude 的 message-level fork)会额外带 --existing-session-id,让新进程复用 hub 上原来那一行会话 而不是新建一行(run.ts:1512-1524)——这是 §8 会话恢复能"原地复活"的前提。

5.6 detached spawn + stderr 尾巴

拉起子进程的三个关键选项(run.ts:665-673):cwd: spawnDirectorydetached: truestdio: ['ignore', 'pipe', 'pipe']。stdout/stderr 之所以要 pipe,是为了留一条 4000 字符的 stderr 尾巴:

const MAX_TAIL_CHARS = 4000;
...
happyProcess.stderr?.on('data', (data) => { stderrTail = appendTail(stderrTail, data); });

run.ts:647-677。子进程起不来的时候,这段尾巴会被裁到 800 字符拼进错误消息里 (buildWebhookFailureMessage,run.ts:723-749),再通过 reportSpawnOutcomeToHub 写进 hub 上的 runnerState.lastSpawnError(run.ts:1169-1205)。于是手机上能直接看到"为什么没开起来", 不用 ssh 回家翻日志。

spawnHappyCLI 返回没有 pid 时还有一段专门处理:先 setImmediate 让异步的 'error' 事件有机会触发, 再把 spawn 错误一起拼进消息(run.ts:679-705)。

5.7 回调闭环:新进程怎么把自己报回来

子进程正常起来后走 bootstrapSession(cli/src/agent/sessionFactory.ts:199), 在拿到 hub 分配的 sessionId 之后调 reportSessionStarted(sessionFactory.ts:185), 它转身打本地 HTTP:

export async function notifyRunnerSessionStarted(sessionId: string, metadata: Metadata) {
return await runnerPost('/session-started', { sessionId, metadata });
}

cli/src/runner/controlClient.ts:84-92runnerPost 自己会读 runner.state.json 拿端口、 校验 pid 还活着,然后 POST 到 http://127.0.0.1:<httpPort>(controlClient.ts:38-82)。

runner 这头的 onHappySessionWebhook(run.ts:418)metadata.hostPid 匹配, 而不是用 sessionId——因为 spawn 那一刻 runner 还不知道 sessionId:

const pid = sessionMetadata.hostPid;
...
const existingSession = pidToTrackedSession.get(pid);

run.ts:421-431。匹配上就填回 happySessionId 并 resolve awaiter,spawnSession 的 promise 落地, sessionId 顺着 RPC 原路返回给 Web。

5.8 等待超时与"幽灵会话"防御

等待窗口默认 15 秒,可用 HAPI_RUNNER_WEBHOOK_TIMEOUT_MS 调大(run.ts:388-392)。 注释解释了为什么需要调大:opus[1m] + --resume 在限流或大会话恢复时,实测要 30 秒到 60 分钟才走到回调。

超时之后做四件事(run.ts:810-844):

  1. 删掉 awaiter;
  2. pidToTrackedSession 里的条目也删掉——这是"幽灵会话"防御的一半;
  3. killProcessByChildProcess 整棵进程树杀掉(wrapper + 真正的 claude/codex 孙子进程),带 SIGTERM→SIGKILL 升级;
  4. 若是 worktree 会话,挂一次性 exit 监听,等孩子真的死了再删工作树。

另一半防御在 webhook 回来的路上。假如一个被判超时的孤儿进程迟到才上报,runner 已经没有它的跟踪条目了。 这时怎么区分"终端里用户自己起的会话"和"迟到的孤儿"?靠 webhook 自报的 startedBy:

if (sessionMetadata.startedBy === 'runner') {
// 迟到的孤儿:不认,直接杀
void killProcess(pid);
return;
}

run.ts:466-479。终端起的会诚实地报 'terminal',所以在无跟踪条目的前提下还声称 'runner' 的, 只可能是那个孤儿——不认领、直接杀,避免它在 Web 上变成一行操作不了的幽灵会话。

反过来,终端里直接跑 hapi 的会话走的是同一个回调,被登记成 startedBy: 'hapi directly - likely by user from terminal'(run.ts:482-487), 于是 runner 也能顺带管理它们(hapi runner list 能看到、stop-session 能停)。


6. git worktree 会话:一个会话一棵工作树

sessionType: 'worktree' 让 runner 先开一棵独立的 git 工作树再在里面起会话, 这样多个 agent 可以并行改同一个仓库而互不打架。

布局是仓库的兄弟目录(不是仓库内部,避免污染 .gitignore):

~/code/myrepo/ ← repoRoot
~/code/myrepo-worktrees/ ← repoParent/`${repoName}-worktrees`
└── 0817-a3f2/ ← name(日期前缀+随机后缀,或用户给的 nameHint slug)
分支 hapi-0817-a3f2

依据 cli/src/runner/worktree.ts:118-128(createWorktree);建树命令是 git worktree add -b <branch> <path>(worktree.ts:139)。命名冲突时最多重试 5 次, 每次换一个随机后缀(worktree.ts:125-137,MAX_ATTEMPTS = 5)。

Cursor 走另一条路。 Cursor Agent 自己有原生 --worktree(放在 ~/.cursor/worktrees/ 下), runner 就不自己建了,改为直接把 --cursor-worktree 传下去:

if (agent === 'cursor') {
spawnDirectory = directory;
logger.debug(`[RUNNER RUN] Cursor-native worktree requested ...`);
} else {
const worktreeResult = await createWorktree({ basePath: directory, nameHint: worktreeName });

cli/src/runner/run.ts:550-580;参数拼接在 run.ts:1558-1568。理由写在注释里: 让 Cursor 的沙箱和 skills 看到它自己预期的目录布局。

清理时的保护。 删工作树的入口是 maybeCleanupWorktree,它先看孩子还活着没:

const pid = happyProcess?.pid;
if (pid && isProcessAlive(pid)) {
logger.debug(`[RUNNER RUN] Skipping worktree cleanup after ${reason}; child still running`, ...);
return;
}

cli/src/runner/run.ts:594-607。因为孩子可能还在往工作树里写文件, git worktree remove --force(worktree.ts:170)删下去就是数据丢失。 超时路径因此改用"挂 exit 监听、等树杀干净了再删"的写法(run.ts:832-836)。


7. 会话自举:metadata 里写了什么,Web 才显示得出来

新进程起来后要向 hub 自我介绍两份东西,都在 cli/src/agent/sessionFactory.ts:

机器元数据 buildMachineMetadata(sessionFactory.ts:45)——host、platform、CLI 版本、 homeDir、happyHomeDir、happyLibDir、workspaceRoots。runner 注册机器时用的是同一个函数 (run.ts:1124),所以 Web 上"机器卡片"的信息和会话里报的是一致的。

会话元数据 buildSessionMetadata(sessionFactory.ts:80)。挑几个承重字段:

字段谁在用
hostPidprocess.pidrunner 用它把 webhook 匹配回 pidToTrackedSession(§5.7)
machineId本机 idhub 决定"恢复这个会话该找哪台机器"
path工作目录hub 恢复会话时的 directory(§8)
startedBy / startedFromRunner'runner' / true幽灵会话判别(§5.8)、Web 上标注来源
worktreereadWorktreeEnv() 的结果Web 显示"这个会话在哪棵工作树 / 哪个分支"
flavoragent 名hub 决定恢复时用哪套 resume 语法

worktree 字段的来源有意思:优先读 runner 注入的 HAPI_WORKTREE_* 环境变量, 读不到再退回去问 git(比较 --git-dir--git-common-dir 是否不同来判断"我在不在工作树里")—— 见 cli/src/utils/worktreeEnv.ts:9-11(readWorktreeEnv)与 :38(readWorktreeFromGit)。 所以终端里手动 cd 进工作树起的会话,也能被正确标注。


8. 会话恢复:hub 出主意,runner 出力气

"恢复一个归档会话"表面是一个按钮,实际是 hub 与 runner 的分工:hub 负责想清楚该用什么参数重开, runner 负责真的把进程拉起来。三个 hub 侧函数按调用深度排列:

reopenSession(1780) ← Web 点"重新打开"
├─ 活着 → 直接返回(幂等)
├─ 已归档 → 清掉归档元数据 → 转 resumeSession;失败则回滚归档快照
└─ 未归档但不活跃 → 直接转 resumeSession


resumeSession(1514)
├─ resolveLocalResumeTarget(1167) → { directory, flavor, agentSessionId }
├─ 挑一台在线机器(cursor 强制原机器)
└─ rpcGateway.spawnSession(..., resumeToken, ..., existingSessionId=原 sessionId)


等 waitForSessionActive

行号均在 hub/src/sync/syncEngine.ts

resolveLocalResumeTarget(syncEngine.ts:2414)是"能不能恢复"的判定中心,它要凑齐两样:

  1. metadata.path——重开在哪个目录(没有就报 resume_unavailable);
  2. agent 原生的会话 id——由 resolveAgentResumeId(syncEngine.ts:2389)按 flavor 分别取 codexSessionId / claudeSessionId / cursorSessionId / piSessionId …, claude 与 codex 取不到时还会尝试从历史消息里反推(recoverClaudeSessionIdFromMessages, 细节见 04 Hub 的状态与同步)。

listLocalResumableSessions(syncEngine.ts:2461)就是对整个 namespace 跑一遍上面的解析、 把成功的挑出来,再按 machineId 过滤、按 updatedAt 倒序——这就是 Web 上"可恢复会话"那个列表的数据来源 (路由 hub/src/web/routes/cli.ts:152)。

落到 runner 这边其实没有新机制:resumeToken 变成 resumeSessionId、原 sessionId 变成 existingSessionId, 一起进 buildCliArgs(§5.5),后面完全走 §5 那条 spawn 链路。恢复 = 带 resume 参数的 spawn, 这是这套设计能保持简单的关键。

有一处防回归的细节值得看:reopenSession 在转发 resumeSession 之前会先清掉归档元数据, 一旦 resume 失败就把归档快照恢复回去(syncEngine.ts:3293-3329), 否则会留下一行"不活跃、又不算归档"的悬空记录。


9. 巧妙之处(可以带走的技术)

  • 关机是一个带来源的 promise,不是散落的 exit。 四类来源统一收敛到一个 requestShutdown, 来源最后同步到 hub,运维在手机上就能分辨"人为停的"还是"崩的"(run.ts:128-178run.ts:1434-1439)。
  • 交接协议的核心是"父亲最后才死"。 旧协议让新进程先杀老进程,任何失败都造成永久离线; 新协议把"我起来了"的证据定义成状态文件里出现另一个活着的 pid,父亲确认后才退 (run.ts:1278-1384controlClient.ts:248)。
  • 版本判断锚在 mtime,不锚在版本号。 同版本号重发布、本地重编都能被识别(controlClient.ts:17)。
  • 节流不许污染事实。 重试节流用独立的 nextHandoffAttemptAt,而不是去改 startedWithCliMtimeMs, 因为后者是要写进状态文件、被外部工具当"真相"读的(run.ts:1214-1223)。
  • webhook 用 PID 而不是 sessionId 匹配。 spawn 那一刻 sessionId 还不存在,PID 是唯一已知的握手凭据 (run.ts:421)。
  • startedBy 兼作真伪判别。 同一个字段既是展示信息,又是"迟到孤儿 vs 终端会话"的鉴别依据 (run.ts:466-479)。
  • 失败信息主动推到云端。 stderr 尾巴 → runnerState.lastSpawnError,让排障不必回到那台机器 (run.ts:1169-1205)。
  • 路径守卫先 realpath 再比较,且能处理不存在的路径。 符号链接逃逸是这类"远程指定 cwd"功能的经典漏洞 (apiMachine.ts:390)。
  • 一次性凭据目录。 Codex 的 token 写进 mkdtemp 出来的临时 CODEX_HOME,不碰用户真实配置(run.ts:614-625)。

10. 边界与局限(诚实说)

  • 本地控制服务没有鉴权。 五个端点全靠"只绑 127.0.0.1"防护(controlServer.ts:194), 没有 token / header 校验。同机器上的任意本地进程都能 POST /spawn-session
  • 不配 --workspace-root 就等于不设防。 isWithinWorkspaceRoots 在没有配置根目录时直接返回 true (apiMachine.ts:372),远程可以指定机器上任意路径开会话。
  • 优雅关机预算约 1.1 秒。 requestShutdown 里的强退定时器是无条件挂上的(run.ts:134-141), 清理超时会以退出码 1 结束。
  • runner 重启会丢会话跟踪。 内存里的 pidToTrackedSession 不落盘,只有"待恢复的会话进程" 会写进 runner.state.json.resume-processes.json(run.ts:329-380)。其余会话重启后 runner 就不认识了 (仓库自己的 cli/src/runner/README.md:549 也把这条列为待改进项)。
  • 默认 15 秒的回调窗口对慢启动不够。 需要靠 HAPI_RUNNER_WEBHOOK_TIMEOUT_MS 手动调(run.ts:388), 没调而超时的进程会被整树杀掉。
  • Gemini 已被硬性拒绝。 spawnSessionbuildCliArgs 各有一处直接抛错 (run.ts:502-504run.ts:1469-1471),理由写在错误消息里;历史 Gemini 会话仍可只读查看, 见 06 多 agent、远程终端与外围
  • worktree 创建失败的重试只覆盖命名冲突。 git worktree add 本身报错就直接返回失败,不重试 (worktree.ts:150-156)。

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

主题文件路径符号名
守护进程主体cli/src/runner/run.tsstartRunner
关机来源与信号处理cli/src/runner/run.tsrequestShutdowncleanupAndShutdown
远程开会话核心cli/src/runner/run.tsspawnSession
会话回调落地cli/src/runner/run.tsonHappySessionWebhookonChildExited
心跳与自更新交接cli/src/runner/run.tsrestartOnStaleVersionAndHeartbeatHANDOFF_RETRY_BACKOFF_MS
命令行参数拼装cli/src/runner/run.tsbuildCliArgs
本地控制服务cli/src/runner/controlServer.tsstartRunnerControlServer
本地控制客户端cli/src/runner/controlClient.tsnotifyRunnerSessionStartedstopRunnerwaitForRunnerHandoff
版本/身份漂移判断cli/src/runner/controlClient.tsisRunnerRunningCurrentlyInstalledHappyVersiongetInstalledCliMtimeMs
独占锁与状态文件cli/src/persistence.tsacquireRunnerLockreleaseRunnerLockwriteRunnerStateRunnerLocallyPersistedState
目录三态校验cli/src/runner/validateWorkspaceDirectory.tsvalidateWorkspaceDirectorydescribeMkdirError
git 工作树cli/src/runner/worktree.tscreateWorktreeremoveWorktree
机器 Socket.IO 与机器级 RPCcli/src/api/apiMachine.tsApiMachineClientsetRPCHandlersisWithinWorkspaceRootsresolveForWorkspaceCheck
会话元数据自举cli/src/agent/sessionFactory.tsbuildMachineMetadatabuildSessionMetadatabootstrapSession
worktree 环境变量读取cli/src/utils/worktreeEnv.tsreadWorktreeEnvreadWorktreeFromGit
CLI 子命令入口cli/src/commands/runner.tsrunnerCommandextractWorkspaceRootArgs
hub 侧 RPC 网关hub/src/sync/rpcGateway.tsRpcGateway.spawnSessionmachineRpc
hub 侧恢复编排hub/src/sync/syncEngine.tsresumeSessionreopenSessionresolveLocalResumeTargetlistLocalResumableSessions
hub 侧 spawn 路由hub/src/web/routes/machines.tsPOST /machines/:id/spawn(:65)
RPC 方法名常量shared/src/rpcMethods.tsRPC_METHODS.SpawnHappySession

下一章: 换一种 agent 要改哪些地方、手机上那个真 shell 怎么接到工作机 → 06 多 agent、远程终端与外围

回头看:会话起来之后本地终端与手机怎么抢方向盘 → 02 本地/远程双模接管; 拓扑与协议底座 → 01 三方拓扑与协议底座