数据截至 (上游 commit eb980a5c9eea)
守护进程与机器:从手机凭空开一个新会话
30 秒导读: 本章讲 Happy 里比「会话」更大的那层实体——机器,以及守在机器上的常驻进程 daemon。 读完你能回答:手机上并没有终端,为什么点一下就能在家里的 Mac 上开出一个全新的 Claude 会话?
前一章讲的都是「已经存在的会话」怎么被远程驱动(见 04-remote-turn-and-permissions.md)。 本章讲的是会话还不存在时谁来把它造出来。传输机制(加密 RPC 怎么经服务器中继)在 02-sync-and-rpc.md,这里只讲用这条管道做什么。
1. 先弄清两个实体:会话 vs 机器
Happy 的服务器上有两类长期对象,它们的生命周期完全不同:
| 实体 | 对应现实中的什么 | 谁在本地代表它 | 活多久 |
|---|---|---|---|
| Session(会话) | 一次与 Claude / Codex 的对话 | 一个 happy claude 进程 | 从开一次对话到进程退出 |
| Machine(机器) | 一台电脑 | 一个常驻的 happy daemon 进程 | 电脑开着就一直在 |
为什么必须有「机器」这一层? 因为会话是被造出来的东西,总得有人造。手机上没有你的文件系统、没有 claude 可执行文件、没有你的 API token;这些只在你那台电脑上。于是每台电脑常驻一个 daemon,它替这台电脑在服务器上登记为一个 Machine,然后挂一条长连接等着接活。
daemon 接的活大致三类:
- 造会话:在某个目录下起一个新的 agent 会话(
spawn-happy-session)。 - 改会话的历史:复制/截断一份对话记录,做 fork 与 rewind(
claude-fork-session等)。 - 当这台机器的手脚:跑 bash、读写文件、列目录、ripgrep、difftastic。
用起来什么样
手机端 App 上的动作,落到代码里就是一次 machine-scoped RPC:
// 示意,非源码:App 端「在这台机器上新建会话」按钮背后
await machineSpawnNewSession({
machineId: 'machine-uuid-123', // 选中的那台电脑
directory: '/Users/me/projects/foo',
agent: 'claude',
})
// → 返回 { type: 'success', sessionId: 'happy-session-...' }
// App 随后直接路由进这个新会话的聊天页
真实实现是 packages/happy-app/sources/sync/ops.ts:258(machineSpawnNewSession),它把方法名拼成 ${machineId}:spawn-happy-session 发给服务器(packages/happy-app/sources/sync/apiSocket.ts:172,machineRPC)。重点看这个前缀:会话 RPC 用 sessionId 作用域,机器 RPC 用 machineId 作用域,走的是同一条中继机制。
2. 顶层全景:一次「凭空开会话」的完整链路
先给一句话读图指引:从左到右是请求方向,最右边那个新进程是被造出来的产物,它造好后自己回头连服务器。
手机 App Happy 服务器 你的电脑
┌─────────┐ rpc-call ┌──────────┐ rpc-request ┌──────────────────┐
│ 选机器 │ ────────► │ 按前缀 │ ──────────► │ daemon 进程 │
│ 选目录 │ │ machineId │ │ (machine-scoped) │
└─────────┘ ◄──────── │ 路由 │ ◄────────── │ spawnSession() │
▲ result └──────────┘ result └────────┬─────────┘
│ ▲ │ spawn(tmux 或 detached)
│ │ ▼
│ │ ┌──────────────────┐
│ │ 新会话自己注册 │ happy claude 进程 │
│ └───────────────────│ (session-scoped) │
│ └────────┬─────────┘
│ 会话出现在列表里 │ HTTP 本机回调
└──────────────────────────────────────────── /session-started
(daemon 靠这一步才知道 sessionId)
部件职责一览:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ApiMachineClient | 机器这一侧的 socket 客户端:注册所有 machine RPC、同步 daemonState、20 秒心跳 | packages/happy-cli/src/api/apiMachine.ts:115 |
startDaemon() | daemon 的一生:抢锁、认证、起控制服务器、连服务器、心跳、优雅退出 | packages/happy-cli/src/daemon/run.ts:79 |
spawnSession() | 真正把一个新会话进程拉起来 | packages/happy-cli/src/daemon/run.ts:278 |
| 本机控制服务器 | 127.0.0.1 上的 HTTP,接收新会话上报、供 CLI 命令查询/停机 | packages/happy-cli/src/daemon/controlServer.ts:15 |
machineUpdateHandler | 服务器侧:机器心跳、metadata / daemonState 的乐观并发写 | packages/happy-server/sources/app/api/socket/machineUpdateHandler.ts:10 |
machinesRoutes | 服务器侧:机器实体的 REST(注册/查询/ 删除) | packages/happy-server/sources/app/api/routes/machinesRoutes.ts:11 |
3. 机器实体:两份数据、两个版本号、一个在线灯
服务器上一台机器存的是两块互相独立的密文,各带各的版本号:
| 字段 | 装什么 | 变化频率 | 谁写 |
|---|---|---|---|
metadata | 主机名、平台、CLI 版本、home 目录、装了哪些 agent CLI | 很少 | daemon(能力变化时)/ App(改名) |
daemonState | status、pid、本机 HTTP 端口、启动时间、关停原因 | 频繁 | daemon |
两者的 schema 见 packages/happy-cli/src/api/types.ts:130(MachineMetadataSchema)与 :158(DaemonStateSchema);初始 metadata 在 packages/happy-cli/src/daemon/run.ts:68(initialMachineMetadata)拼好,里面塞了 detectCLIAvailability() 的探测结果——手机上「这台机器能不能起 Codex」的那个灰按钮,就来自这里。
写冲突怎么办:带版本号的 CAS
daemon 和手机可能同时改同一台机器(手机改名 vs daemon 更新状态)。写入走的是乐观并发:客户端带上 expectedVersion,服务器用它当 SQL 的 where 条件做原子比较交换。
daemon: machine-update-state { expectedVersion: 3 }
│
▼
服务器 updateMany(where: { daemonStateVersion: 3 }) ──► count=1 ─► success, version=4
│ └─► count=0 ─► version-mismatch + 当前值
▼
客户端收到 mismatch → 采纳更高版本的密文 → 抛错 → backoff 重试
服务器侧的原子 CAS 在 machineUpdateHandler.ts:187-199(updateMany 带 daemonStateVersion: expectedVersion),客户端侧「采纳更新版本再重试」在 apiMachine.ts:417-423(updateDaemonState 的 version-mismatch 分支)。这套版本比较与会话那一侧的 metadataVersion / agentStateVersion 是同一套机制,详见 02-sync-and-rpc.md。
一个容易忽略的细节: 只有写 daemonState 时服务器才顺手把机器标成 active: true 并刷新 lastActiveAt(machineUpdateHandler.ts:196-197),改 metadata 时明确不动这两个字段(:102 的注释)。也就是说「机器在线」这件事被绑在运行时状态上,而不是随便一次改名。
在线灯的三个来源
| 信号 | 触发时机 | 代码 |
|---|---|---|
| socket 连上/断开 | 立即广播 machine online / offline 的 ephemeral 事件 | packages/happy-server/sources/app/api/socket.ts:161-170、:195-200 |
machine-alive | daemon 每 20 秒一次 | apiMachine.ts:546-548 发;machineUpdateHandler.ts:13 收 |
daemonState.status | 启动写 running、关停前写 shutting-down | apiMachine.ts:451-457、run.ts:997-1002 |
服务器对心跳做了时间戳消毒:未来时间被夹到「现在」,超过 10 分钟的陈旧心跳直接丢弃(machineUpdateHandler.ts:27-33)。
4. daemon 的一生
怎么被拉起来
用户几乎不用手动开 daemon。任何一次 happy claude 启动,认证之后立刻调 ensureDaemonRunning()(packages/happy-cli/src/index.ts:779)。
happy claude
│
├─ 已有 daemon 且版本一致? ── 是 ─► 直接返回
│ └ 否 ─► spawn 'daemon start-sync'(detached, stdio ignore)
│ │
│ └─ 轮询 100ms,最多 5 秒,等它写状态文件 + HTTP 应答
▼
继续启动本次会话
ensureDaemonRunning 全文只有 39 行(packages/happy-cli/src/daemon/ensureDaemonRunning.ts:9),关键是那段等就 绪的轮询:不等的话,会话马上要发的 /session-started webhook 会打空,daemon 就永远不知道这个会话存在。
happy daemon start 也只是把自己 detached 地重新执行成 daemon start-sync(index.ts:535-561),真正的常驻逻辑在 start-sync 里跑 startDaemon()。
启动序列
startDaemon() 的顺序是有讲究的(daemon/run.ts:79 起):
① 装信号处理器 + shutdown promise(SIGINT/SIGTERM/uncaught/unhandledRejection 全部收敛成一个 requestShutdown)
② 版本比对:跑着的 daemon 是不是当前安装的版本?
├ 是 → console.log('Daemon already running with matching version') 然后 exit(0)
└ 不是 → stopDaemon() 干掉老的,自己接班
③ acquireDaemonLock(5, 200):抢排他锁,抢不到就退出
④ startCaffeinate():macOS 上阻止睡 眠
⑤ authAndSetupMachineIfNeeded():拿凭据 + 本地 machineId
⑥ 从磁盘恢复历史会话的加密材料(跨 daemon 重启的 resume 能力)
⑦ startDaemonControlServer():127.0.0.1 随机端口
⑧ getOrCreateMachine() → machineSyncClient() → setRPCHandlers() → connect()
⑨ 60 秒心跳循环
⑩ await shutdown promise
第 ②、③ 步值得单独说:
- 版本检查读的是
daemon.state.json里的startedWithCliVersion,和本次 bundle 里编译进去的版本比(daemon/controlClient.ts:185,isDaemonRunningCurrentlyInstalledHappyVersion)。注释里记着为什么不再读磁盘上的package.json:那样会在 manifest 版本与 bundle 版本不一致时形成无限自重启循环(controlClient.ts:199-212,issue #1107)。 - 锁文件的获取是连 PID 内容一起原子化的:先把 PID 写进私有临时文件,再 hard-link 到锁路径,
link遇到已存在会像O_EXCL一样失败(packages/happy-cli/src/persistence.ts:341-353)。这样任何「锁在但里面没有活 PID」的情况都能被无歧义判为陈旧锁。