baa-conductor

git clone 

commit
bbded8a
parent
e4e3f32
author
im_wower
date
2026-03-22 12:09:42 +0800 CST
Remove HA and historical artifacts
86 files changed,  +333, -10951
M DESIGN.md
+99, -2681
   1@@ -1,2738 +1,156 @@
   2 # BAA Conductor 实施设计说明
   3 
   4-## 0. 文档状态
   5+## 1. 当前目标
   6 
   7-本文档是新 conductor 系统的实施基线。
   8+`baa-conductor` 现在只维护一条最小可运行路径:
   9 
  10-## 0.1 当前有效部署决策
  11+- `mini` 是唯一长期运行节点
  12+- `mini` 负责 conductor、status-api、自启动与本地日志
  13+- `control-api.makefile.so` 负责 Cloudflare Worker + D1 控制面
  14+- `conductor.makefile.so` 作为公网入口,经 VPS Nginx 回源到 `mini`
  15+- 浏览器控制面来自 [`plugins/baa-firefox`](./plugins/baa-firefox)
  16 
  17-从 `2026-03-22` 起,当前有效目标已经简化为:
  18+历史的主备切换、failover、switchback、多节点公网入口已经从当前主线删除。需要回看旧设计时,使用 tag `ha-failover-archive-2026-03-22`。
  19 
  20-- 只保留 `mini` 作为唯一中控
  21-- 只保留 `mini` 的自启动、控制面、运行态检查与公网入口
  22-- 不再继续推进 `mac` 备主、planned failover、emergency failover、switchback
  23+## 2. 当前拓扑
  24 
  25-下面保留的主备/failover 内容,视为历史设计记录,不再是当前执行目标。
  26+### 控制面
  27 
  28-它的细节深度是按“多个 Codex worker 读完就能并行开工”来写的。
  29+- `control-api.makefile.so`
  30+- Cloudflare Worker
  31+- D1 作为控制平面数据库
  32 
  33-本文档定义了:
  34+控制面承担:
  35 
  36-- 系统目标
  37-- 拓扑结构
  38-- 域名与转发布局
  39-- 历史上的 `mini` / `mac` 主备切换行为
  40-- Cloudflare D1 控制平面设计
  41-- task 与 step 模型
  42-- `planner`、`conductor`、`worker` 的职责边界
  43-- Codex 执行约定
  44-- 日志、checkpoint 与恢复机制
  45-- Firefox 插件集成方式
  46-- 建议的仓库结构
  47-- 建议的并行开发拆分
  48+- 系统模式读写
  49+- controller heartbeat
  50+- 任务、step、run 的元数据存储
  51+- 浏览器面板和 CLI 的统一接口
  52 
  53-如果代码与本文档不一致,要么更新代码以符合本文档,要么明确修订本文档。
  54+### 执行面
  55 
  56-## 1. 总体目标
  57+- `mini`
  58+- Tailscale IP: `100.71.210.78`
  59+- 本地 conductor: `http://100.71.210.78:4317`
  60+- 本地 status-api: `http://100.71.210.78:4318`
  61 
  62-构建一个稳定的 AI 执行系统,具备以下特征:
  63+### 公网入口
  64 
  65-- `mini` 是唯一长期运行的 conductor。
  66-- `mini` 承担自启动、控制面、本地状态面和浏览器控制集成。
  67-- 如需额外开发机或临时 worker,视为辅助资源,不再纳入主备设计目标。
  68-- 人类只使用一个可见的 Claude `control` 对话。
  69-- 自动化使用一个隐藏的 Claude `dispatch` 通道。
  70-- task 真相存储在共享数据库中。
  71-- 进度恢复依赖 checkpoint,而不是试图复活已死亡的进程。
  72+- `https://conductor.makefile.so`
  73+- VPS Nginx 反向代理到 `100.71.210.78:4317`
  74 
  75-整个系统围绕一个简单原则展开:
  76+### 浏览器控制面
  77 
  78-**恢复 task 进度,而不是恢复 Codex 进程内存。**
  79+- Firefox 插件代码位于 [`plugins/baa-firefox`](./plugins/baa-firefox)
  80+- 浏览器通过 `control-api` 读取系统状态
  81+- 浏览器通过 `pause / resume / drain` 接口控制自动化
  82 
  83-## 2. 现有基础组件
  84+## 3. 组件职责
  85 
  86-当前环境已经有三个关键基础组件。
  87+### `apps/control-api-worker`
  88 
  89-### 2.1 `baa-hand`
  90+- Cloudflare Worker 入口
  91+- 负责 D1 读写
  92+- 对浏览器、CLI 和 conductor 暴露统一 HTTP API
  93 
  94-当前职责:
  95+### `apps/conductor-daemon`
  96 
  97-- AI 路由
  98-- `POST /ask`
  99-- `POST /chat`
 100-- `POST /plan`
 101+- `mini` 本地常驻进程
 102+- 负责 heartbeat、租约续约、最小调度循环和只读探针
 103+- 对外暴露:
 104+  - `/healthz`
 105+  - `/readyz`
 106+  - `/rolez`
 107+  - `/v1/runtime`
 108 
 109-当前优点:
 110+### `apps/status-api`
 111 
 112-- 能调 Claude
 113-- 能调 Codex
 114-- Codex 和 Claude 网页版都能用它
 115+- `mini` 本地状态读取面
 116+- 面向浏览器或人类查看
 117+- 默认监听本地或显式指定的 `100.x`
 118 
 119-当前限制:
 120+### `apps/worker-runner`
 121 
 122-- 现在的 Codex 集成是面向进程的,不是面向 task 的
 123-- 它会等到进程退出后再返回
 124-- 它还不是一个可持久恢复的 step worker runtime
 125+- 本地 step 执行器
 126+- 负责本地目录、日志、checkpoint 落盘
 127 
 128-结论:
 129+## 4. 部署约束
 130 
 131-- 继续用 `baa-hand` 走 planner 和 review 路径
 132-- 不要把当前 `baa-hand -> codex exec -> 等进程退出` 这条路径当成最终的 durable worker 模型
 133+- 只保留 `mini` 单节点
 134+- 不依赖 MagicDNS
 135+- 内网地址直接写 Tailscale `100.x`
 136+- 外网只暴露 `control-api.makefile.so` 与 `conductor.makefile.so`
 137+- 当前不要求 Cloudflare proxy;DNS-only 也可接受
 138 
 139-### 2.2 `baa-shell`
 140+## 5. Runtime 目录
 141 
 142-当前职责:
 143+默认仓库路径建议:
 144 
 145-- shell 命令执行
 146-- 文件读写
 147-- task 报告
 148+- `/Users/george/code/baa-conductor`
 149 
 150-结论:
 151+默认 runtime 目录:
 152 
 153-- 它仍然适合远程操作流和外部自动化
 154-- conductor 在 HTTP 执行比直连 SSH 或本地 exec 更方便时,可以调用它
 155+- `state/`
 156+- `runs/`
 157+- `worktrees/`
 158+- `logs/`
 159+- `tmp/`
 160 
 161-### 2.3 Firefox 插件
 162+这些目录由 [`scripts/runtime/bootstrap.sh`](./scripts/runtime/bootstrap.sh) 初始化。
 163 
 164-当前职责:
 165+## 6. 自启动
 166 
 167-- 拦截 Claude 网页流量
 168-- 已有常驻 controller 概念
 169-- 可以承载可见的自动化状态与控制按钮
 170-
 171-结论:
 172-
 173-- 用它接可见的 `control` 会话
 174-- 增加全局 `pause`、`resume`,可选 `drain`
 175-- 不要让浏览器状态成为真相来源
 176-- 浏览器按钮必须写入 conductor 控制平面
 177-
 178-## 3. 设计原则
 179-
 180-### 3.1 Durable 真相必须在 AI 进程之外
 181-
 182-真相不能放在:
 183-
 184-- 浏览器标签页
 185-- Codex 进程
 186-- 临时 Claude 会话
 187-- Node.js 进程内存中的 map
 188-
 189-真相必须放在:
 190-
 191-- Cloudflare D1
 192-- 本地 run 目录
 193-
 194-### 3.2 Codex 是 Worker,不是 Conductor
 195-
 196-Codex 可以:
 197-
 198-- 在被要求时参与规划任务
 199-- 执行某个 step
 200-- 总结结果
 201-
 202-Codex 不可以:
 203-
 204-- 持有 lease
 205-- 持有队列真相
 206-- 持有调度真相
 207-- 直接改全局状态
 208-
 209-### 3.3 Planner 是抽象角色
 210-
 211-`planner` 是角色,不是固定实现方。
 212-
 213-planner 可以是:
 214-
 215-- 确定性的模板
 216-- Claude dispatch
 217-- Codex
 218-- 未来单独的 planner 模块
 219-
 220-是否使用 planner、是否接受 planner 输出,由 conductor 决定。
 221-
 222-### 3.4 Conductor 必须是确定性基础设施
 223-
 224-conductor 是 daemon,不是聊天会话。
 225-
 226-conductor 必须负责:
 227-
 228-- task 创建归一化
 229-- step 拆分结果验收
 230-- lease 逻辑
 231-- worker 分配
 232-- heartbeat 检查
 233-- timeout 处理
 234-- checkpoint 写入
 235-- 恢复
 236-
 237-### 3.5 Step 边界就是恢复边界
 238-
 239-每个 task 都要拆成多个 step。
 240-
 241-每个 step 都必须:
 242-
 243-- 有边界
 244-- 可观察
 245-- 可 checkpoint
 246-- 可重试或可终止
 247-
 248-## 4. 系统角色
 249-
 250-## 4.1 Human
 251-
 252-人只需要做这些事:
 253-
 254-- 使用一个可见的 Claude `control` 对话
 255-- 查看当前状态
 256-- 暂停、恢复或 drain 自动化
 257-- 做高层决策
 258-
 259-人不应该需要盯着 Codex 终端界面。
 260-
 261-## 4.2 `control`
 262-
 263-这是人类交互使用的可见 Claude 对话。
 264-
 265-职责:
 266-
 267-- 讨论策略
 268-- 创建 task
 269-- 查看状态
 270-- 请求暂停或恢复
 271-- 审查进展
 272-
 273-规则:
 274-
 275-- `control` 是交互界面
 276-- `control` 不是任务队列
 277-- `control` 不是 durable 状态存储
 278-
 279-## 4.3 `dispatch`
 280-
 281-这是隐藏的 Claude 自动化通道。
 282-
 283-职责:
 284-
 285-- 提供 planning 支持
 286-- 提供 review 支持
 287-- 输出结构化结果
 288-
 289-规则:
 290-
 291-- `dispatch` 不面向用户
 292-- `dispatch` 不能做 durable 真相来源
 293-- `dispatch` 只能给 proposal,不能直接做最终状态迁移
 294-
 295-## 4.4 `planner`
 296-
 297-职责:
 298-
 299-- 把目标转换成一个建议的 step plan
 300-
 301-输入:
 302-
 303-- task 目标
 304-- repo
 305-- 约束
 306-- 验收条件
 307-- 可选的当前 repo 状态
 308-
 309-输出:
 310-
 311-- 结构化 plan JSON
 312-- 拆分理由
 313-- 风险标记
 314-
 315-规则:
 316-
 317-- planner 输出只是建议
 318-- conductor 在持久化之前必须校验 planner 输出
 319-
 320-## 4.5 `conductor`
 321-
 322-职责:
 323-
 324-- leader lease 管理
 325-- task 归一化
 326-- plan 验收
 327-- step 调度
 328-- worker 监管
 329-- timeout 执行
 330-- 日志索引
 331-- checkpoint
 332-- 故障切换与恢复
 333-
 334-## 4.6 `worker`
 335-
 336-职责:
 337-
 338-- 执行且只执行一个 step
 339-- 输出本地日志
 340-- 返回结果
 341-- 退出
 342-
 343-默认 AI worker 是 Codex。
 344-
 345-Shell 和 Git 类 step 可以由 shell runner 执行,而不是由 Codex 进程执行。
 346-
 347-## 5. 拓扑结构
 348-
 349-## 5.1 节点
 350-
 351-### `mini`
 352-
 353-角色:
 354-
 355-- 主 conductor
 356-- 本地 worker 宿主机
 357-
 358-运行:
 359-
 360-- conductor daemon
 361-- worker runner
 362-- 可选本地 status API
 363-- 可选 Firefox 自动化栈
 364-
 365-### `mac`
 366-
 367-角色:
 368-
 369-- 备用 conductor
 370-- 本地 worker 宿主机
 371-
 372-运行:
 373-
 374-- standby conductor daemon
 375-- worker runner
 376-- 可选本地 status API
 377-
 378-### `vps`
 379-
 380-角色:
 381-
 382-- 入口与反向代理
 383-
 384-运行:
 385-
 386-- Nginx
 387-- TLS 终止,或 Cloudflare origin 代理支持
 388-
 389-VPS 不持有 leader 真相。
 390-
 391-### `cloudflare`
 392-
 393-角色:
 394-
 395-- D1 数据库
 396-- 可选 Worker 形式的 control API
 397-- DNS 与自定义域名
 398-
 399-## 5.2 数据路径
 400-
 401-### 控制平面
 402-
 403-- human -> 可见 Claude `control`
 404-- `control` -> conductor control API
 405-- conductors -> D1
 406-- workers -> conductors
 407-
 408-### 执行平面
 409-
 410-- conductor -> 本地 worker 进程
 411-- conductor -> 本地 shell 与 git 命令
 412-- conductor -> `baa-hand` 做 planning 或 review
 413-- conductor -> `baa-shell` 在 HTTP 远程执行更方便时使用
 414-
 415-### 浏览器平面
 416-
 417-- Firefox 插件 -> control API 做 pause 与 resume
 418-- Firefox 插件 -> 可见状态 badge
 419-- Firefox 插件 -> 如有需要,访问隐藏 `dispatch`
 420-
 421-## 6. 域名、二级域名与内网地址布局
 422-
 423-本节定义推荐的双通道布局:
 424-
 425-- 公网流量走二级域名,经 VPS Nginx 转发
 426-- 内网与节点间流量直接走 Tailscale `100.x` 地址
 427-
 428-这里明确不依赖 MagicDNS 名称。
 429-
 430-原因:
 431-
 432-- 当前环境里 ClashX 会和 MagicDNS 产生 DNS 接管冲突
 433-- 即使 tailnet 已开启 MagicDNS,也不把 `*.ts.net` 作为生产配置依赖
 434-- 内网配置统一写死到 Tailscale IPv4 地址,避免本机 DNS 状态影响运行
 435-
 436-## 6.1 必需的公网域名
 437-
 438-### `conductor.makefile.so`
 439-
 440-用途:
 441-
 442-- conductor API 的统一公网入口
 443-- 指向 VPS Nginx
 444-- Nginx 再转发到 `mini` 主、`mac` 备
 445-
 446-使用方:
 447-
 448-- 人类工具
 449-- Firefox 插件控制动作
 450-- 诊断请求
 451-
 452-### `mini-conductor.makefile.so`
 453-
 454-用途:
 455-
 456-- 经 VPS 直达 mini,便于调试和维护
 457-
 458-### `mac-conductor.makefile.so`
 459-
 460-用途:
 461-
 462-- 经 VPS 直达 mac,便于调试和维护
 463-
 464-### `control-api.makefile.so`
 465-
 466-用途:
 467-
 468-- 绑定到 D1 的 Cloudflare Worker 自定义域
 469-- durable 控制状态的规范写入口
 470-
 471-名字可以不同,但设计上要求有一个稳定的、前置 D1 的 HTTP API 域名。
 472-
 473-## 6.2 必需的内网地址
 474-
 475-当前推荐直接使用这些 Tailscale 地址:
 476-
 477-- `mini` -> `100.71.210.78`
 478-- `mac` -> `100.112.239.13`
 479-- `racknerd-ff37952` -> `100.68.201.85`
 480-
 481-用途:
 482-
 483-- `mini <-> mac` 的节点间控制流量
 484-- conductor 到 peer conductor 的探活、状态读取、受保护节点 API
 485-- worker、status、运维脚本等内部调用
 486-- VPS Nginx 到 mini/mac 的 upstream 回源
 487-
 488-规则:
 489-
 490-- 生产配置中不写 `mini.tail0125d.ts.net`
 491-- 生产配置中不写 `mbp.tail0125d.ts.net`
 492-- 不要求业务机器开启 `accept-dns`
 493-- 只要求它们加入同一个 tailnet 且 `100.x` 地址可达
 494-
 495-如果未来 Tailscale 地址变化:
 496-
 497-- 先更新中央配置
 498-- 再更新 VPS Nginx upstream
 499-- 最后重载相关进程
 500-
 501-## 6.3 现有域名
 502-
 503-这些域名已经存在,继续沿用:
 504-
 505-- `led.makefile.so` -> `baa-hand`
 506-- `s.makefile.so` -> `baa-shell`
 507-
 508-## 6.4 推荐 DNS 策略
 509-
 510-- `conductor.makefile.so` -> VPS 公网 IP
 511-- `mini-conductor.makefile.so` -> VPS 公网 IP
 512-- `mac-conductor.makefile.so` -> VPS 公网 IP
 513-- `control-api.makefile.so` -> Cloudflare Worker 自定义域
 514-
 515-之后由 VPS 把节点专属域名再转发到 Tailscale `100.x` 地址。
 516-
 517-额外说明:
 518-
 519-- 公网 DNS 不承载 tailnet 内部寻址
 520-- tailnet 内部寻址直接用 `100.x`
 521-- 不使用 MagicDNS 名称做回源或服务发现
 522-
 523-## 6.5 建议的 DNS 记录
 524-
 525-推荐 DNS 记录如下:
 526-
 527-| Hostname | Type | Target | Purpose |
 528-| --- | --- | --- | --- |
 529-| `conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | conductor 统一公网入口 |
 530-| `mini-conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | 经 VPS 直达 mini |
 531-| `mac-conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | 经 VPS 直达 mac |
 532-| `control-api.makefile.so` | Worker 自定义域 | Cloudflare Worker | durable 控制平面 API |
 533-| `led.makefile.so` | 现有 | 现有 origin | `baa-hand` |
 534-| `s.makefile.so` | 现有 | 现有 origin | `baa-shell` |
 535-
 536-说明:
 537-
 538-- 如果 VPS 走 Cloudflare 代理,所有相关 host 的代理模式要保持一致
 539-- `control-api.makefile.so` 最好留在 Cloudflare 内部,让 D1 访问保持本地化
 540-- mini 和 mac 不应该直接暴露在公网,节点专属域名仍然应该经过 VPS
 541-- tailnet 内部机器间调用不要再绕回这些公网域名
 542-
 543-## 6.6 TLS 策略
 544-
 545-推荐 TLS 模式:
 546-
 547-- 公网访问的域名尽量通过 Cloudflare 代理
 548-- VPS 上给 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 申请 Let’s Encrypt 证书
 549-- Worker 自定义域使用 Cloudflare 托管证书
 550-
 551-如果 conductor 域名启用了 Cloudflare 橙云:
 552-
 553-- VPS 上配置 origin certificate 或普通 Let’s Encrypt 证书
 554-- 尽量限制对 origin 的直接访问
 555-
 556-如果 conductor 域名不走 Cloudflare 代理:
 557-
 558-- 保持 VPS 上 Let’s Encrypt 自动续期
 559-- 对直连节点域名加鉴权保护
 560-
 561-## 7. VPS 与 Nginx 转发
 562-
 563-## 7.1 为什么需要 Nginx
 564-
 565-VPS 上的 Nginx 提供:
 566-
 567-- 稳定的公网入口
 568-- TCP 与 HTTP 层的机器切换
 569-- 到 mini 和 mac 的直连调试路由
 570-
 571-它不负责决定真实 leader。
 572-
 573-真实 leader 由 D1 lease 决定。
 574-
 575-它也不承载大部分内网控制流量。
 576-
 577-正确分工:
 578-
 579-- 浏览器、Claude 网页、管理页面 UI 走公网二级域名
 580-- mini/mac 之间的内部控制流量直接走 Tailscale `100.x`
 581-- VPS 只作为公网入口,不作为内网总线
 582-
 583-## 7.2 Nginx Upstream 设计
 584-
 585-推荐 upstream:
 586-
 587-```nginx
 588-upstream conductor_primary {
 589-    server 100.71.210.78:4317 max_fails=2 fail_timeout=5s;
 590-    server 100.112.239.13:4317 backup;
 591-    keepalive 32;
 592-}
 593-
 594-upstream mini_conductor_direct {
 595-    server 100.71.210.78:4317;
 596-    keepalive 16;
 597-}
 598-
 599-upstream mac_conductor_direct {
 600-    server 100.112.239.13:4317;
 601-    keepalive 16;
 602-}
 603-```
 604-
 605-本例中:
 606-
 607-- `100.71.210.78` 是 `mini`
 608-- `100.112.239.13` 是 `mac`
 609-- `4317` 是 conductor 本地 HTTP 端口
 610-- upstream 使用 Tailscale IPv4 地址,不使用 `*.ts.net` 名称
 611-- 这样可以绕开 ClashX 与 MagicDNS 的 DNS 接管冲突
 612-
 613-## 7.3 Nginx Server Block
 614-
 615-### 统一 conductor 入口
 616-
 617-```nginx
 618-server {
 619-    listen 443 ssl http2;
 620-    server_name conductor.makefile.so;
 621-
 622-    ssl_certificate     /etc/letsencrypt/live/conductor.makefile.so/fullchain.pem;
 623-    ssl_certificate_key /etc/letsencrypt/live/conductor.makefile.so/privkey.pem;
 624-
 625-    location / {
 626-        proxy_pass http://conductor_primary;
 627-        proxy_http_version 1.1;
 628-        proxy_set_header Host $host;
 629-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 630-        proxy_set_header X-Forwarded-Proto $scheme;
 631-        proxy_set_header X-Request-Id $request_id;
 632-        proxy_connect_timeout 3s;
 633-        proxy_read_timeout 60s;
 634-        proxy_send_timeout 60s;
 635-    }
 636-}
 637-```
 638-
 639-### 直达 mini 的路由
 640-
 641-```nginx
 642-server {
 643-    listen 443 ssl http2;
 644-    server_name mini-conductor.makefile.so;
 645-
 646-    ssl_certificate     /etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem;
 647-    ssl_certificate_key /etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem;
 648-
 649-    location / {
 650-        proxy_pass http://mini_conductor_direct;
 651-        proxy_http_version 1.1;
 652-        proxy_set_header Host $host;
 653-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 654-        proxy_set_header X-Forwarded-Proto $scheme;
 655-        proxy_set_header X-Request-Id $request_id;
 656-    }
 657-}
 658-```
 659-
 660-### 直达 mac 的路由
 661-
 662-```nginx
 663-server {
 664-    listen 443 ssl http2;
 665-    server_name mac-conductor.makefile.so;
 666-
 667-    ssl_certificate     /etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem;
 668-    ssl_certificate_key /etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem;
 669-
 670-    location / {
 671-        proxy_pass http://mac_conductor_direct;
 672-        proxy_http_version 1.1;
 673-        proxy_set_header Host $host;
 674-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 675-        proxy_set_header X-Forwarded-Proto $scheme;
 676-        proxy_set_header X-Request-Id $request_id;
 677-    }
 678-}
 679-```
 680-
 681-## 7.4 健康检查与就绪检查
 682-
 683-每个 conductor 都应暴露:
 684-
 685-- `GET /healthz`
 686-- `GET /readyz`
 687-- `GET /rolez`
 688-
 689-推荐语义:
 690-
 691-- `/healthz`: 进程活着
 692-- `/readyz`: 节点已准备好提供 conductor 服务
 693-- `/rolez`: 返回 `leader` 或 `standby`
 694-
 695-重要说明:
 696-
 697-- standby 可以健康,但不是 leader
 698-- 只要节点不是 leader,写 API 仍然必须拒绝调度类操作
 699-
 700-## 7.5 建议的端口暴露方式
 701-
 702-VPS 只应对公网暴露:
 703-
 704-- `80/tcp`
 705-- `443/tcp`
 706-
 707-mini 和 mac 不应该直接对公网开放 conductor 端口。
 708-
 709-推荐访问模型:
 710-
 711-- 浏览器、人类运维、Claude 网页、管理 UI -> 公网二级域名 -> VPS -> Tailscale upstream
 712-- mini 和 mac 的 daemon / worker / 脚本 -> 直接访问 Tailscale `100.x`
 713-- 只有需要公网暴露的入口才经过 VPS
 714-- 运维人员要访问 mini/mac 时,可通过 SSH、Tailscale `100.x`,或受保护的直连节点域名
 715-
 716-内网建议:
 717-
 718-- `mini` 本机调用本机 conductor:`http://127.0.0.1:4317`
 719-- `mini` 调 `mac`:`http://100.112.239.13:4317`
 720-- `mac` 调 `mini`:`http://100.71.210.78:4317`
 721-- VPS Nginx upstream 也直接写这两个 `100.x`
 722-
 723-## 7.6 完整的 Nginx 模式
 724-
 725-推荐整体模式:
 726-
 727-1. `:80` 全部跳转到 HTTPS
 728-2. `conductor.makefile.so` 走主加备 upstream
 729-3. `mini-conductor.makefile.so` 直转 mini upstream
 730-4. `mac-conductor.makefile.so` 直转 mac upstream
 731-5. 直连节点域名额外加鉴权
 732-
 733-### HTTP 跳转
 734-
 735-```nginx
 736-server {
 737-    listen 80;
 738-    listen [::]:80;
 739-    server_name conductor.makefile.so mini-conductor.makefile.so mac-conductor.makefile.so;
 740-    return 301 https://$host$request_uri;
 741-}
 742-```
 743-
 744-### 直连节点域名的 Basic Auth
 745-
 746-对 `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so`,增加:
 747-
 748-```nginx
 749-auth_basic "Restricted";
 750-auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor;
 751-```
 752-
 753-除非你希望整个系统都放在认证后面,否则不要把这段加到统一入口域名上。
 754-
 755-## 7.7 Nginx 加固建议
 756-
 757-建议在每个 TLS server block 中增加:
 758-
 759-```nginx
 760-proxy_set_header X-Real-IP $remote_addr;
 761-proxy_set_header X-Forwarded-Host $host;
 762-proxy_set_header X-Forwarded-Port $server_port;
 763-proxy_buffering off;
 764-client_max_body_size 10m;
 765-```
 766-
 767-如果你需要日志串联请求:
 768-
 769-```nginx
 770-add_header X-Request-Id $request_id always;
 771-```
 772-
 773-如果后续要走状态流或长连接:
 774-
 775-```nginx
 776-proxy_read_timeout 3600s;
 777-proxy_send_timeout 3600s;
 778-```
 779-
 780-## 7.8 Nginx Failover 的边界
 781-
 782-Nginx failover 只解决:
 783-
 784-- 网络路径故障
 785-- TCP 连接失败
 786-- 简单 upstream 不可达
 787-
 788-Nginx failover 不能解决:
 789-
 790-- split-brain
 791-- 过期 leader 继续写
 792-- task lease 冲突
 793-- 安全恢复逻辑
 794-
 795-因此所有写接口仍然必须校验 D1 lease 状态。
 796-
 797-## 7.9 建议的 Nginx 文件布局
 798-
 799-建议在 VPS 上使用:
 800-
 801-```text
 802-/etc/nginx/nginx.conf
 803-/etc/nginx/sites-available/baa-conductor.conf
 804-/etc/nginx/sites-enabled/baa-conductor.conf
 805-/etc/nginx/.htpasswd-baa-conductor
 806-```
 807-
 808-未来仓库中的文件应尽量镜像这个结构:
 809-
 810-```text
 811-ops/nginx/baa-conductor.conf
 812-ops/nginx/includes/common-proxy.conf
 813-ops/nginx/includes/direct-node-auth.conf
 814-```
 815-
 816-## 8. 主备与故障切换模型
 817-
 818-## 8.1 真相来源
 819-
 820-leader 真相在 D1,不在 Nginx。
 821-
 822-Nginx 只负责网络可达性和入口层 failover。
 823-
 824-## 8.2 推荐行为
 825-
 826-- `mini` 是首选 leader
 827-- `mac` 是 standby
 828-- 任意时刻只能有一个有效 leader lease
 829-
 830-推荐 lease 参数:
 831-
 832-- TTL: 30 秒
 833-- 续租周期: 每 5 秒一次
 834-- 连续 2 次续租失败后自我降级
 835-
 836-## 8.3 切换规则
 837-
 838-### 正常状态
 839-
 840-- mini 持有 lease
 841-- mac 只监听,不调度
 842-
 843-### Mini 故障
 844-
 845-- mini heartbeat 停止
 846-- lease 过期
 847-- mac 抢到 lease
 848-- mac 成为 active scheduler
 849-
 850-### Mini 恢复
 851-
 852-- mini 恢复上线
 853-- mini 注册为 standby
 854-- mini 不自动抢占
 855-
 856-### 手动切回
 857-
 858-管理员可以显式执行:
 859-
 860-- drain mac
 861-- promote mini
 862-- demote mac
 863-
 864-这样可以避免主备来回抖动。
 865-
 866-## 8.4 防止 Split-Brain
 867-
 868-规则:
 869-
 870-- 所有写动作都要求有效 lease
 871-- claim task 与 claim step 前都要检查 lease
 872-- 每条写路径都要校验当前 leader term
 873-- standby 节点必须拒绝调度类写入
 874-
 875-## 9. Cloudflare D1 控制平面
 876-
 877-## 9.1 为什么使用 D1
 878-
 879-使用 D1 作为共享 durable 协调存储,是因为它适合承载:
 880-
 881-- mini 与 mac 之间的共享真相
 882-- SQLite 事务语义
 883-- 队列、lease、元数据与索引
 884-
 885-D1 不适合存储:
 886-
 887-- 长期保存的完整原始日志
 888-- 大型二进制产物
 889-- 高频逐 token 流式数据
 890-
 891-## 9.2 访问模式
 892-
 893-推荐模式:
 894-
 895-- 机器本身不要直接写 D1
 896-- 全部通过统一的 HTTP control API
 897-- control API 跑在 Cloudflare Worker 上,并绑定 D1
 898-
 899-这样可以集中管理:
 900-
 901-- 鉴权
 902-- schema 访问
 903-- 事务约束
 904-- 版本演进
 905-
 906-## 9.3 读一致性
 907-
 908-控制路径上的读尽量使用主一致读。
 909-
 910-规则:
 911-
 912-- lease 获取与 task claim 必须保证主一致逻辑
 913-- 状态查看类页面可以容忍轻微陈旧
 914-
 915-## 10. 数据库 Schema
 916-
 917-本节定义最小 D1 schema。
 918-
 919-## 10.1 `leader_lease`
 920-
 921-用途:
 922-
 923-- 全局唯一 active leadership lease
 924-
 925-```sql
 926-CREATE TABLE IF NOT EXISTS leader_lease (
 927-  lease_name TEXT PRIMARY KEY,
 928-  holder_id TEXT NOT NULL,
 929-  holder_host TEXT NOT NULL,
 930-  term INTEGER NOT NULL,
 931-  lease_expires_at INTEGER NOT NULL,
 932-  renewed_at INTEGER NOT NULL,
 933-  preferred_holder_id TEXT,
 934-  metadata_json TEXT
 935-);
 936-```
 937-
 938-约定:
 939-
 940-- 只保留一行,`lease_name = 'global'`
 941-
 942-## 10.2 `controllers`
 943-
 944-用途:
 945-
 946-- 注册 conductors 及其 heartbeat
 947-
 948-```sql
 949-CREATE TABLE IF NOT EXISTS controllers (
 950-  controller_id TEXT PRIMARY KEY,
 951-  host TEXT NOT NULL,
 952-  role TEXT NOT NULL,
 953-  priority INTEGER NOT NULL,
 954-  status TEXT NOT NULL,
 955-  version TEXT,
 956-  last_heartbeat_at INTEGER NOT NULL,
 957-  last_started_at INTEGER,
 958-  metadata_json TEXT
 959-);
 960-```
 961-
 962-## 10.3 `workers`
 963-
 964-用途:
 965-
 966-- 注册 worker slot
 967-
 968-```sql
 969-CREATE TABLE IF NOT EXISTS workers (
 970-  worker_id TEXT PRIMARY KEY,
 971-  controller_id TEXT NOT NULL,
 972-  host TEXT NOT NULL,
 973-  worker_type TEXT NOT NULL,
 974-  status TEXT NOT NULL,
 975-  max_parallelism INTEGER NOT NULL DEFAULT 1,
 976-  current_load INTEGER NOT NULL DEFAULT 0,
 977-  last_heartbeat_at INTEGER NOT NULL,
 978-  capabilities_json TEXT,
 979-  metadata_json TEXT
 980-);
 981-```
 982-
 983-## 10.4 `tasks`
 984-
 985-用途:
 986-
 987-- durable task 队列
 988-
 989-```sql
 990-CREATE TABLE IF NOT EXISTS tasks (
 991-  task_id TEXT PRIMARY KEY,
 992-  repo TEXT NOT NULL,
 993-  task_type TEXT NOT NULL,
 994-  title TEXT NOT NULL,
 995-  goal TEXT NOT NULL,
 996-  source TEXT NOT NULL,
 997-  priority INTEGER NOT NULL DEFAULT 100,
 998-  status TEXT NOT NULL,
 999-  planning_strategy TEXT,
1000-  planner_provider TEXT,
1001-  branch_name TEXT,
1002-  base_ref TEXT,
1003-  target_host TEXT,
1004-  assigned_controller_id TEXT,
1005-  current_step_index INTEGER NOT NULL DEFAULT -1,
1006-  constraints_json TEXT,
1007-  acceptance_json TEXT,
1008-  metadata_json TEXT,
1009-  result_summary TEXT,
1010-  result_json TEXT,
1011-  error_text TEXT,
1012-  created_at INTEGER NOT NULL,
1013-  updated_at INTEGER NOT NULL,
1014-  started_at INTEGER,
1015-  finished_at INTEGER
1016-);
1017-```
1018-
1019-索引:
1020-
1021-```sql
1022-CREATE INDEX IF NOT EXISTS idx_tasks_status_priority
1023-ON tasks(status, priority, created_at);
1024-
1025-CREATE INDEX IF NOT EXISTS idx_tasks_repo
1026-ON tasks(repo, created_at);
1027-```
1028-
1029-## 10.5 `task_steps`
1030-
1031-用途:
1032-
1033-- 持久化恢复边界
1034-
1035-```sql
1036-CREATE TABLE IF NOT EXISTS task_steps (
1037-  step_id TEXT PRIMARY KEY,
1038-  task_id TEXT NOT NULL,
1039-  step_index INTEGER NOT NULL,
1040-  step_name TEXT NOT NULL,
1041-  step_kind TEXT NOT NULL,
1042-  status TEXT NOT NULL,
1043-  assigned_worker_id TEXT,
1044-  assigned_controller_id TEXT,
1045-  timeout_sec INTEGER NOT NULL,
1046-  retry_limit INTEGER NOT NULL DEFAULT 0,
1047-  retry_count INTEGER NOT NULL DEFAULT 0,
1048-  lease_expires_at INTEGER,
1049-  input_json TEXT,
1050-  output_json TEXT,
1051-  summary TEXT,
1052-  error_text TEXT,
1053-  created_at INTEGER NOT NULL,
1054-  updated_at INTEGER NOT NULL,
1055-  started_at INTEGER,
1056-  finished_at INTEGER,
1057-  UNIQUE(task_id, step_index)
1058-);
1059-```
1060-
1061-索引:
1062-
1063-```sql
1064-CREATE INDEX IF NOT EXISTS idx_task_steps_task_status
1065-ON task_steps(task_id, status, step_index);
1066-```
1067-
1068-## 10.6 `task_runs`
1069-
1070-用途:
1071-
1072-- 记录 step 的执行尝试
1073-
1074-```sql
1075-CREATE TABLE IF NOT EXISTS task_runs (
1076-  run_id TEXT PRIMARY KEY,
1077-  task_id TEXT NOT NULL,
1078-  step_id TEXT NOT NULL,
1079-  worker_id TEXT NOT NULL,
1080-  controller_id TEXT NOT NULL,
1081-  host TEXT NOT NULL,
1082-  pid INTEGER,
1083-  status TEXT NOT NULL,
1084-  lease_expires_at INTEGER,
1085-  heartbeat_at INTEGER,
1086-  log_dir TEXT NOT NULL,
1087-  stdout_path TEXT,
1088-  stderr_path TEXT,
1089-  worker_log_path TEXT,
1090-  checkpoint_seq INTEGER NOT NULL DEFAULT 0,
1091-  exit_code INTEGER,
1092-  result_json TEXT,
1093-  error_text TEXT,
1094-  created_at INTEGER NOT NULL,
1095-  started_at INTEGER,
1096-  finished_at INTEGER
1097-);
1098-```
1099-
1100-索引:
1101-
1102-```sql
1103-CREATE INDEX IF NOT EXISTS idx_task_runs_task
1104-ON task_runs(task_id, created_at);
1105-
1106-CREATE INDEX IF NOT EXISTS idx_task_runs_step
1107-ON task_runs(step_id, created_at);
1108-```
1109-
1110-## 10.7 `task_checkpoints`
1111-
1112-用途:
1113-
1114-- 恢复 step 中途进度的快照
1115-
1116-这一张表很关键,因为某些 Codex step 很长,在 step 完成前就可能已经产生了有价值的部分进度。
1117-
1118-```sql
1119-CREATE TABLE IF NOT EXISTS task_checkpoints (
1120-  checkpoint_id TEXT PRIMARY KEY,
1121-  task_id TEXT NOT NULL,
1122-  step_id TEXT NOT NULL,
1123-  run_id TEXT NOT NULL,
1124-  seq INTEGER NOT NULL,
1125-  checkpoint_type TEXT NOT NULL,
1126-  summary TEXT,
1127-  content_text TEXT,
1128-  content_json TEXT,
1129-  created_at INTEGER NOT NULL,
1130-  UNIQUE(run_id, seq)
1131-);
1132-```
1133-
1134-checkpoint 类型可以包括:
1135-
1136-- `heartbeat`
1137-- `log_tail`
1138-- `git_diff`
1139-- `summary`
1140-- `test_result`
1141-
1142-## 10.8 `task_logs`
1143-
1144-用途:
1145-
1146-- 存结构化日志尾部与生命周期事件
1147-
1148-```sql
1149-CREATE TABLE IF NOT EXISTS task_logs (
1150-  log_id INTEGER PRIMARY KEY AUTOINCREMENT,
1151-  task_id TEXT NOT NULL,
1152-  step_id TEXT,
1153-  run_id TEXT NOT NULL,
1154-  seq INTEGER NOT NULL,
1155-  stream TEXT NOT NULL,
1156-  level TEXT,
1157-  message TEXT NOT NULL,
1158-  created_at INTEGER NOT NULL
1159-);
1160-```
1161-
1162-## 10.9 `system_state`
1163-
1164-用途:
1165-
1166-- 全局自动化状态
1167-
1168-```sql
1169-CREATE TABLE IF NOT EXISTS system_state (
1170-  state_key TEXT PRIMARY KEY,
1171-  value_json TEXT NOT NULL,
1172-  updated_at INTEGER NOT NULL
1173-);
1174-```
1175-
1176-约定:
1177-
1178-- `state_key = 'automation'`
1179-- value 中包含 `mode = running | draining | paused`
1180-
1181-## 10.10 `task_artifacts`
1182-
1183-可选但推荐。
1184-
1185-用途:
1186-
1187-- 保存 durable 输出的结构化引用
1188-
1189-```sql
1190-CREATE TABLE IF NOT EXISTS task_artifacts (
1191-  artifact_id TEXT PRIMARY KEY,
1192-  task_id TEXT NOT NULL,
1193-  step_id TEXT,
1194-  run_id TEXT,
1195-  artifact_type TEXT NOT NULL,
1196-  path TEXT,
1197-  uri TEXT,
1198-  size_bytes INTEGER,
1199-  sha256 TEXT,
1200-  metadata_json TEXT,
1201-  created_at INTEGER NOT NULL
1202-);
1203-```
1204-
1205-## 11. Control API
1206-
1207-推荐的生产设计是:
1208-
1209-- 一个 Cloudflare Worker
1210-- 一个 D1 binding
1211-- 所有 durable 状态迁移都经过这个 Worker
1212-
1213-## 11.1 Base URL
1214-
1215-推荐:
1216-
1217-- `https://control-api.makefile.so`
1218-
1219-## 11.2 必需接口
1220-
1221-### `POST /v1/controllers/heartbeat`
1222-
1223-由 mini 和 mac conductor 调用。
1224-
1225-Body:
1226-
1227-```json
1228-{
1229-  "controller_id": "mini-main",
1230-  "host": "mini",
1231-  "role": "primary",
1232-  "priority": 100,
1233-  "status": "alive",
1234-  "version": "0.1.0"
1235-}
1236-```
1237-
1238-### `POST /v1/leader/acquire`
1239-
1240-由 conductors 用来获取或续租 lease。
1241-
1242-Body:
1243-
1244-```json
1245-{
1246-  "controller_id": "mini-main",
1247-  "host": "mini",
1248-  "preferred": true,
1249-  "ttl_sec": 30
1250-}
1251-```
1252-
1253-Response:
1254-
1255-```json
1256-{
1257-  "ok": true,
1258-  "holder_id": "mini-main",
1259-  "term": 7,
1260-  "lease_expires_at": 1760000000000,
1261-  "is_leader": true
1262-}
1263-```
1264-
1265-### `POST /v1/tasks`
1266-
1267-创建 task。
1268-
1269-Body:
1270-
1271-```json
1272-{
1273-  "repo": "/Users/george/code/event-fabric",
1274-  "task_type": "feature_impl",
1275-  "title": "Add conductor D1 schema",
1276-  "goal": "Implement the initial D1 schema and migration scripts.",
1277-  "priority": 50,
1278-  "constraints": {
1279-    "target_host": "mini"
1280-  },
1281-  "acceptance": [
1282-    "SQL schema committed",
1283-    "migration runner stub added"
1284-  ],
1285-  "metadata": {
1286-    "requested_by": "control",
1287-    "branch_prefix": "feat"
1288-  }
1289-}
1290-```
1291-
1292-### `POST /v1/tasks/:task_id/plan`
1293-
1294-持久化已通过校验的 step plan。
1295-
1296-该接口由 conductor 在完成 planner 输出校验后调用。
1297-
1298-### `POST /v1/tasks/claim`
1299-
1300-领取待规划 task 或下一个可运行 step。
1301-
1302-也可以拆成:
1303-
1304-- `POST /v1/tasks/claim-planning`
1305-- `POST /v1/steps/claim`
1306-
1307-只要事务语义清晰,这两种形式都可以。
1308-
1309-### `POST /v1/steps/:step_id/heartbeat`
1310-
1311-Body 包含:
1312-
1313-- `run_id`
1314-- `worker_id`
1315-- `lease_expires_at`
1316-- 可选 `checkpoint`
1317-
1318-### `POST /v1/steps/:step_id/checkpoint`
1319-
1320-Body:
1321-
1322-```json
1323-{
1324-  "run_id": "run_001",
1325-  "seq": 3,
1326-  "checkpoint_type": "git_diff",
1327-  "summary": "Current worktree diff after route refactor",
1328-  "content_text": "diff --git ..."
1329-}
1330-```
1331-
1332-### `POST /v1/steps/:step_id/complete`
1333-
1334-### `POST /v1/steps/:step_id/fail`
1335-
1336-### `POST /v1/system/pause`
1337-
1338-### `POST /v1/system/resume`
1339-
1340-### `POST /v1/system/drain`
1341-
1342-### `GET /v1/system/state`
1343-
1344-### `GET /v1/tasks/:task_id`
1345-
1346-### `GET /v1/tasks/:task_id/logs`
1347-
1348-### `GET /v1/runs/:run_id`
1349-
1350-## 11.3 鉴权
1351-
1352-最低要求:
1353-
1354-- conductor、worker 与 control API 之间使用 HMAC token 或共享密钥
1355-- 修改系统状态的操作使用单独的 admin token
1356-
1357-推荐后续演进:
1358-
1359-- 签名 service token
1360-- 按角色拆 scope
1361-
1362-## 11.4 Control API 授权模型
1363-
1364-推荐角色:
1365-
1366-- `controller`
1367-- `worker`
1368-- `browser_admin`
1369-- `ops_admin`
1370-- `readonly`
1371-
1372-推荐权限划分:
1373-
1374-| Role | Allowed Actions |
1375-| --- | --- |
1376-| `controller` | 获取与续租 lease、claim tasks、更新 run 状态 |
1377-| `worker` | heartbeat、checkpoint、完成已分配 step |
1378-| `browser_admin` | pause、resume、drain、查看队列 |
1379-| `ops_admin` | promote、demote、维护操作 |
1380-| `readonly` | 状态查看、日志、面板 |
1381-
1382-推荐实现:
1383-
1384-- `Authorization: Bearer <token>`
1385-- token 元数据包含角色与可选主机身份
1386-- 修改接口既校验角色,也校验资源归属
1387-
1388-## 11.5 Control API 运维约束
1389-
1390-Worker 应提供:
1391-
1392-- 尽量做到幂等写入
1393-- 对陈旧 term 明确返回冲突
1394-- 记录 request id
1395-- 输出结构化错误体
1396-
1397-推荐错误格式:
1398-
1399-```json
1400-{
1401-  "ok": false,
1402-  "error": "not_leader",
1403-  "message": "This node does not hold the active leadership lease.",
1404-  "request_id": "req_123"
1405-}
1406-```
1407-
1408-## 12. Task 模型
1409-
1410-## 12.1 Task 生命周期
1411-
1412-Task 状态:
1413-
1414-- `queued`
1415-- `planning`
1416-- `running`
1417-- `paused`
1418-- `done`
1419-- `failed`
1420-- `canceled`
1421-
1422-状态流转:
1423-
1424-- `queued -> planning`
1425-- `planning -> running`
1426-- `planning -> failed`
1427-- `running -> paused`
1428-- `paused -> running`
1429-- `running -> done`
1430-- `running -> failed`
1431-- `running -> canceled`
1432-
1433-## 12.2 Task 输入 Schema
1434-
1435-推荐归一化格式:
1436-
1437-```json
1438-{
1439-  "task_id": "task_001",
1440-  "repo": "/Users/george/code/event-fabric",
1441-  "task_type": "feature_impl",
1442-  "title": "Implement task scheduler",
1443-  "goal": "Add the initial scheduler and step claim logic.",
1444-  "priority": 50,
1445-  "constraints": {
1446-    "target_host": "mini",
1447-    "write_scope": [
1448-      "services/conductor-daemon/**",
1449-      "packages/db/**"
1450-    ]
1451-  },
1452-  "acceptance": [
1453-    "claim endpoint exists",
1454-    "scheduler handles one runnable step",
1455-    "tests added"
1456-  ],
1457-  "metadata": {
1458-    "branch_prefix": "feat",
1459-    "requested_by": "control"
1460-  }
1461-}
1462-```
1463-
1464-## 12.3 分支策略
1465-
1466-一个 task 对应一个分支。
1467-
1468-规则:
1469-
1470-- 分支由 conductor 创建
1471-- worker 不自己发明分支名
1472-- 分支命名应尽量确定性
1473-
1474-推荐命名:
1475-
1476-- `feat/T-001-d1-schema`
1477-- `feat/T-002-step-claim`
1478-- `fix/T-003-checkpoint-recovery`
1479-
1480-## 13. Step 模型
1481-
1482-## 13.1 Step 状态
1483-
1484-- `pending`
1485-- `running`
1486-- `done`
1487-- `failed`
1488-- `timeout`
1489-
1490-## 13.2 Step 类型
1491-
1492-推荐 step kind:
1493-
1494-- `planner`
1495-- `codex`
1496-- `shell`
1497-- `git`
1498-- `review`
1499-- `finalize`
1500-
1501-## 13.3 Step 合约
1502-
1503-每个 step 都必须具备:
1504-
1505-- 明确的目标边界
1506-- 输入 payload
1507-- timeout
1508-- retry 策略
1509-- 完成后的 summary
1510-
1511-## 13.4 恢复边界
1512-
1513-step 完成是主要恢复边界。
1514-
1515-但因为 Codex step 可能很长,所以系统还必须维护 step 中途的 checkpoint。
1516-
1517-## 14. Planning 模型
1518-
1519-## 14.1 Planner 所有权
1520-
1521-task 拆 step 的所有权属于 conductor。
1522-
1523-含义是:
1524-
1525-- planner 只负责提案
1526-- conductor 决定接受并持久化
1527-- worker 只负责执行
1528-
1529-## 14.2 规划策略选择
1530-
1531-conductor 需要决定:
1532-
1533-- `template_first`
1534-- `planner_assisted`
1535-- `manual`
1536-
1537-例如:
1538-
1539-- 标准 bugfix -> `template_first`
1540-- 模糊的大范围重构 -> `planner_assisted`
1541-- 紧急运维变更 -> `manual`
1542-
1543-## 14.3 Planner 输出 Schema
1544-
1545-推荐 planner 输出:
1546-
1547-```json
1548-{
1549-  "task_type": "feature_impl",
1550-  "strategy": "planner_assisted",
1551-  "reasoning": "The task touches scheduler, persistence, and recovery logic. Split into deterministic steps.",
1552-  "steps": [
1553-    {
1554-      "step_name": "prepare_branch",
1555-      "step_kind": "git",
1556-      "timeout_sec": 120,
1557-      "retry_limit": 0,
1558-      "input": {}
1559-    },
1560-    {
1561-      "step_name": "inspect_context",
1562-      "step_kind": "codex",
1563-      "timeout_sec": 600,
1564-      "retry_limit": 1,
1565-      "input": {
1566-        "goal": "Read the codebase and summarize where scheduler logic belongs."
1567-      }
1568-    },
1569-    {
1570-      "step_name": "implement_scheduler",
1571-      "step_kind": "codex",
1572-      "timeout_sec": 1800,
1573-      "retry_limit": 1,
1574-      "input": {
1575-        "goal": "Implement scheduler claim loop and state transition logic."
1576-      }
1577-    },
1578-    {
1579-      "step_name": "run_tests",
1580-      "step_kind": "shell",
1581-      "timeout_sec": 1200,
1582-      "retry_limit": 1,
1583-      "input": {
1584-        "command": "npm test"
1585-      }
1586-    },
1587-    {
1588-      "step_name": "commit_push",
1589-      "step_kind": "git",
1590-      "timeout_sec": 300,
1591-      "retry_limit": 0,
1592-      "input": {}
1593-    }
1594-  ]
1595-}
1596-```
1597-
1598-## 14.4 Planner 校验规则
1599-
1600-conductor 校验规则:
1601-
1602-- step 名称必须已知,或被明确允许
1603-- step 顺序必须合法
1604-- timeout 不能无上限
1605-- step 输入不能为空缺
1606-- 不允许 planner 指令直接做状态迁移
1607-
1608-## 15. 推荐的 Task 模板
1609-
1610-## 15.1 `feature_impl`
1611-
1612-推荐默认步骤:
1613-
1614-1. `prepare_branch`
1615-2. `inspect_context`
1616-3. `implement`
1617-4. `run_tests`
1618-5. `review_fix`
1619-6. `commit_push`
1620-7. `finalize`
1621-
1622-## 15.2 `bugfix`
1623-
1624-推荐默认步骤:
1625-
1626-1. `prepare_branch`
1627-2. `reproduce`
1628-3. `implement_fix`
1629-4. `run_targeted_tests`
1630-5. `commit_push`
1631-6. `finalize`
1632-
1633-## 15.3 `review_only`
1634-
1635-推荐默认步骤:
1636-
1637-1. `prepare_context`
1638-2. `analyze_diff`
1639-3. `write_review`
1640-4. `finalize`
1641-
1642-## 15.4 `ops_change`
1643-
1644-推荐默认步骤:
1645-
1646-1. `prepare_branch`
1647-2. `inspect_ops_context`
1648-3. `edit_ops_files`
1649-4. `validate_config`
1650-5. `commit_push`
1651-6. `finalize`
1652-
1653-## 15.5 `infra_bootstrap`
1654-
1655-推荐默认步骤:
1656-
1657-1. `prepare_branch`
1658-2. `inspect_current_ops_state`
1659-3. `edit_dns_or_nginx_docs`
1660-4. `validate_nginx_config`
1661-5. `commit_push`
1662-6. `finalize`
1663-
1664-## 16. Conductor 详细职责
1665-
1666-conductor 必须承担以下职责。
1667-
1668-### 16.1 启动时
1669-
1670-- 在 `controllers` 中注册自身
1671-- 启动 heartbeat 循环
1672-- 如果有资格则尝试获取 lease
1673-- 加载本地未完成 runs
1674-- 对本地过期 runs 做对账
1675-
1676-### 16.2 Leader Loop
1677-
1678-如果是 leader:
1679-
1680-- 规划 queued tasks
1681-- claim runnable steps
1682-- 分配 workers
1683-- 监管 runs
1684-- 持久化 checkpoints
1685-
1686-如果是 standby:
1687-
1688-- 只发 heartbeat
1689-- 不调度新工作
1690-- 随时准备在当前 leader 过期后接管 lease
1691-
1692-### 16.3 Worker 分配
1693-
1694-conductor 负责选择:
1695-
1696-- host
1697-- worker slot
1698-- worktree 路径
1699-- run 目录
1700-- timeout 预算
1701-
1702-### 16.4 Timeout 执行
1703-
1704-conductor 负责:
1705-
1706-- step timeout
1707-- lease 过期
1708-- 从 `SIGTERM` 升级到 `SIGKILL`
1709-
1710-### 16.5 恢复
1711-
1712-重启后:
1713-
1714-- 扫描本地 run 目录
1715-- 标记 orphaned runs
1716-- 对比本地状态与 D1 状态
1717-- 视情况重新入队或恢复
1718-
1719-## 17. Worker 模型
1720-
1721-## 17.1 为什么 Worker 要短生命周期
1722-
1723-短生命周期 worker 更容易:
1724-
1725-- 隔离故障
1726-- 收集日志
1727-- 管理资源
1728-- 在崩溃后恢复
1729-
1730-## 17.2 Step 执行合约
1731-
1732-worker 合约是:
1733-
1734-1. 接收一个 step
1735-2. 执行一个 step
1736-3. 输出本地日志
1737-4. 输出结构化结果
1738-5. 退出
1739-
1740-worker 不负责:
1741-
1742-- 管理队列
1743-- 持有 lease
1744-- 选择下一个 step
1745-
1746-## 17.3 Worker 类型
1747-
1748-### Codex Worker
1749-
1750-用于:
1751-
1752-- 阅读上下文
1753-- 修改代码
1754-- 输出总结
1755-
1756-### Shell Worker
1757-
1758-用于:
1759-
1760-- 测试
1761-- lint
1762-- 文件检查
1763-- build 命令
1764-
1765-### Git Worker
1766-
1767-用于:
1768-
1769-- 建分支
1770-- commit
1771-- push
1772-- 重置 per-task worktree
1773-
1774-## 18. Codex Worker 合约
1775-
1776-## 18.1 关键约束
1777-
1778-Codex 不以进程恢复为目标。
1779-
1780-系统恢复的是:
1781-
1782-- task
1783-- step
1784-- 最新 checkpoint
1785-
1786-系统不恢复:
1787-
1788-- 已死亡 Codex 进程内部的精确推理状态
1789-
1790-## 18.2 Codex Step 行为
1791-
1792-对于 Codex step:
1793-
1794-- conductor 准备 prompt 与上下文文件
1795-- conductor 启动 Codex
1796-- Codex 把输出写入本地日志
1797-- conductor 周期性抓取 checkpoint
1798-- step 完成后 Codex 退出
1799-
1800-## 18.3 为什么这比当前 `baa-hand` 的 Codex 模式更好
1801-
1802-当前 `baa-hand` 的 Codex 集成会一直等到进程退出,然后才返回输出。
1803-
1804-对于 durable worker 系统,这不够,因为:
1805-
1806-- 它不会把进度持续写成 durable 状态
1807-- 它不支持 checkpoint 恢复
1808-- 它没有把 task 状态和进程状态分离
1809-
1810-因此:
1811-
1812-- 当前 `baa-hand` 的 Codex 路径继续保留给临时用途
1813-- 生产自动化要走 conductor 自己持有的 Codex worker 路径
1814-
1815-## 18.4 Codex 结果 Schema
1816-
1817-推荐最终 step 输出:
1818-
1819-```json
1820-{
1821-  "ok": true,
1822-  "summary": "Implemented scheduler claim loop and added tests.",
1823-  "needs_human": false,
1824-  "blocked": false,
1825-  "artifacts": [],
1826-  "metrics": {
1827-    "duration_sec": 812
1828-  }
1829-}
1830-```
1831-
1832-可选 hint:
1833-
1834-```json
1835-{
1836-  "ok": false,
1837-  "summary": "Need failing test output from package runtime.",
1838-  "blocked": true,
1839-  "suggested_followup": [
1840-    {
1841-      "step_name": "collect_runtime_test_output",
1842-      "step_kind": "shell"
1843-    }
1844-  ]
1845-}
1846-```
1847-
1848-这些 hint 只是 proposal,不是直接状态修改。
1849-
1850-## 19. 日志模型
1851-
1852-## 19.1 本地完整日志
1853-
1854-每个 run 都有一个本地目录:
1855-
1856-```text
1857-<repo>/.baa-conductor/runs/<task-id>/<run-id>/
1858-  meta.json
1859-  worker.log
1860-  stdout.log
1861-  stderr.log
1862-  checkpoints/
1863-    0001-summary.json
1864-    0002-git-diff.patch
1865-```
1866-
1867-本地完整日志是详细取证记录。
1868-
1869-## 19.2 D1 日志索引
1870-
1871-D1 中只存:
1872-
1873-- run 元数据
1874-- 生命周期事件
1875-- 周期性 tail chunk
1876-- 最新 checkpoint 指针
1877-
1878-不应该把全部字节都长期塞进 D1。
1879-
1880-## 19.3 日志事件
1881-
1882-关键生命周期事件包括:
1883-
1884-- task created
1885-- planner selected
1886-- plan accepted
1887-- step claimed
1888-- worker started
1889-- checkpoint persisted
1890-- timeout
1891-- step done
1892-- task done
1893-- failover occurred
1894-
1895-## 20. Checkpoint 模型
1896-
1897-这是稳定性的关键部分。
1898-
1899-## 20.1 为什么需要 Checkpoint
1900-
1901-如果 Codex 改了 20 分钟代码,机器突然死掉,只按 step 边界恢复,就会丢掉所有未提交进度。
1902-
1903-因此,长 AI step 需要 step 中途 checkpoint。
1904-
1905-## 20.2 Checkpoint 类型
1906-
1907-推荐类型:
1908-
1909-- `summary`
1910-- `git_diff`
1911-- `log_tail`
1912-- `test_output`
1913-
1914-## 20.3 Git Diff Checkpoint
1915-
1916-对于 Codex 编辑类 step,conductor 应周期性快照:
1917-
1918-- `git status --short`
1919-- `git diff --binary`
1920-- 可选 `git diff --stat`
1921-
1922-推荐频率:
1923-
1924-- 每 30 到 60 秒一次
1925-- 或在检测到有意义的文件变化后执行
1926-
1927-## 20.4 Checkpoint 存储策略
1928-
1929-MVP 期的策略:
1930-
1931-- 最新 checkpoint summary 存入 D1
1932-- patch 文本大小合理时,一并写入 D1
1933-- 完整 checkpoint 文件保存在本地
1934-
1935-如果 patch 太大:
1936-
1937-- D1 里只保留截断 summary
1938-- 完整 patch 只保留在本地
1939-- 后续可选接入 R2
1940-
1941-## 20.5 从 Checkpoint 恢复
1942-
1943-如果 mini 在执行 Codex step 时宕机:
1944-
1945-1. mac 成为 leader
1946-2. mac 发现 run lease 已过期
1947-3. mac 创建新的 worktree
1948-4. mac checkout task branch 或 base ref
1949-5. 如果存在 checkpointed diff,则回放最新 diff
1950-6. 从最近一个可接受 checkpoint 继续,或直接重跑该 step
1951-
1952-这不是 bit-perfect 的进程恢复。
1953-
1954-这是 durable 的 task 恢复,并支持部分 patch 回放。
1955-
1956-## 21. Timeout 与 Retry 规则
1957-
1958-## 21.1 Timeout 所有权
1959-
1960-timeout 由 conductor 负责。
1961-
1962-worker 不对整个 task 自己做 timeout 判定。
1963-
1964-## 21.2 进程处理
1965-
1966-推荐升级顺序:
1967-
1968-1. 发送 `SIGTERM`
1969-2. 等待 5 秒
1970-3. 发送 `SIGKILL`
1971-4. 标记 run 为 `timeout`
1972-5. 根据策略决定 retry 或 fail
1973-
1974-## 21.3 Retry 规则
1975-
1976-推荐默认值:
1977-
1978-- planner steps: 1 次重试
1979-- codex inspect steps: 1 次重试
1980-- codex implement steps: 1 次重试
1981-- test steps: 基础设施失败时 1 次重试,确定性失败时 0 次
1982-- git commit/push: 默认 0 次,除非 push 因瞬时网络原因失败
1983-
1984-## 21.4 失败分类
1985-
1986-需要明确区分:
1987-
1988-- `timeout`
1989-- `worker_crash`
1990-- `infra_unreachable`
1991-- `deterministic_failure`
1992-- `blocked`
1993-
1994-这个分类会直接影响 retry 策略。
1995-
1996-## 22. Pause、Drain 与 Resume
1997-
1998-## 22.1 全局模式
1999-
2000-全局自动化模式:
2001-
2002-- `running`
2003-- `draining`
2004-- `paused`
2005-
2006-## 22.2 语义
2007-
2008-### `running`
2009-
2010-- 正常运行
2011-
2012-### `draining`
2013-
2014-- 不再启动新的 step
2015-- 已启动的 run 可以自然结束
2016-- 适合计划内切换或维护
2017-
2018-### `paused`
2019-
2020-- 不再启动新的 step
2021-- conductor 调度暂停
2022-- 已运行任务是否继续或终止,由策略决定
2023-
2024-## 22.3 真相来源
2025-
2026-全局模式存储在 D1 的 `system_state` 中。
2027-
2028-浏览器 UI 只是控制面板,不是真相来源。
2029-
2030-## 23. Firefox 插件集成
2031-
2032-## 23.1 浏览器职责
2033-
2034-Firefox 插件应负责:
2035-
2036-- 展示当前全局自动化模式
2037-- 提供 `pause`、`resume`,可选 `drain`
2038-- 展示当前 leader host
2039-- 如有需要,展示队列深度与 active task 数
2040-
2041-## 23.2 浏览器控制流
2042-
2043-可见的 `control` 对话仍然只用于人机交互。
2044-
2045-插件不应混用:
2046-
2047-- 你的交互式 `control` 对话
2048-- 自动化 task dispatch
2049-
2050-推荐模型:
2051-
2052-- 一个可见 `control` 对话
2053-- 一个隐藏 `dispatch` 通道
2054-
2055-## 23.3 浏览器按钮
2056-
2057-最少需要这些按钮:
2058-
2059-- `Pause`
2060-- `Resume`
2061-- 可选 `Drain`
2062-
2063-按钮应调用:
2064-
2065-- `POST /v1/system/pause`
2066-- `POST /v1/system/resume`
2067-- `POST /v1/system/drain`
2068-
2069-## 23.4 浏览器状态显示
2070-
2071-最低显示项:
2072-
2073-- 当前 mode
2074-- 当前 leader
2075-- active runs
2076-- queued tasks
2077-
2078-## 24. `baa-hand` 与 `baa-shell` 集成
2079-
2080-## 24.1 `baa-hand`
2081-
2082-`baa-hand` 适用于:
2083-
2084-- planner 调用
2085-- review 调用
2086-- 非 durable 的临时 AI 工作
2087-
2088-推荐:
2089-
2090-- planner 与 review steps 调 `led.makefile.so`
2091-- execution steps 不依赖当前 `baa-hand` 的 Codex 模式来保证 durability
2092-
2093-## 24.2 `baa-shell`
2094-
2095-`baa-shell` 适用于:
2096-
2097-- 当 HTTP 比 SSH 更方便时,执行远程 shell
2098-- 让外部自动化读写文件
2099-- 提供状态页面
2100-
2101-对于同机操作,本地 conductor 代码依然可以直接用 local exec。
2102-
2103-## 25. mini 与 mac 上的本地目录布局
2104-
2105-推荐根路径:
2106-
2107-```text
2108-/Users/george/code/baa-conductor
2109-```
2110-
2111-推荐 runtime 目录:
2112-
2113-```text
2114-/Users/george/code/baa-conductor/state/
2115-/Users/george/code/baa-conductor/runs/
2116-/Users/george/code/baa-conductor/worktrees/
2117-/Users/george/code/baa-conductor/logs/
2118-/Users/george/code/baa-conductor/tmp/
2119-```
2120-
2121-推荐的 per-run 目录:
2122-
2123-```text
2124-/Users/george/code/baa-conductor/runs/<task-id>/<run-id>/
2125-  meta.json
2126-  state.json
2127-  stdout.log
2128-  stderr.log
2129-  worker.log
2130-  checkpoints/
2131-  artifacts/
2132-```
2133-
2134-推荐的 worktree 目录:
2135-
2136-```text
2137-/Users/george/code/baa-conductor/worktrees/<task-id>/
2138-```
2139-
2140-规则:
2141-
2142-- 一个 task 恰好一个 worktree
2143-- worktree 路径应确定性
2144-- 陈旧 worktree 只能由 conductor 清理,worker 不得自行清理
2145-
2146-## 25.1 本地文件约定
2147-
2148-建议由 conductor 写入的文件:
2149-
2150-- `meta.json`: 不变的 run 元数据
2151-- `state.json`: 当前本地状态镜像
2152-- `worker.log`: 当前 run 的 conductor 生命周期消息
2153-- `stdout.log`: worker 原始 stdout
2154-- `stderr.log`: worker 原始 stderr
2155-
2156-建议作为 checkpoint 写入的文件:
2157-
2158-- `checkpoints/0001-summary.json`
2159-- `checkpoints/0002-git-diff.patch`
2160-- `checkpoints/0003-test-output.txt`
2161-
2162-## 26. 建议的仓库结构
2163-
2164-此仓库最终建议长成这样:
2165-
2166-```text
2167-apps/
2168-  control-api-worker/
2169-  conductor-daemon/
2170-  status-api/
2171-  worker-runner/
2172-packages/
2173-  db/
2174-  planner/
2175-  schemas/
2176-  step-templates/
2177-  logging/
2178-  git-tools/
2179-  checkpointing/
2180-ops/
2181-  nginx/
2182-  sql/
2183-  launchd/
2184-  scripts/
2185-docs/
2186-  decisions/
2187-```
2188-
2189-## 26.1 建议最先创建的文件
2190-
2191-为了方便并行开发,初始仓库至少应包含:
2192-
2193-```text
2194-README.md
2195-DESIGN.md
2196-ops/sql/schema.sql
2197-ops/sql/migrations/0001_init.sql
2198-ops/nginx/baa-conductor.conf
2199-ops/launchd/so.makefile.baa-conductor.plist
2200-ops/launchd/so.makefile.baa-worker-runner.plist
2201-apps/control-api-worker/src/index.ts
2202-apps/conductor-daemon/src/index.ts
2203-apps/worker-runner/src/index.ts
2204-packages/schemas/src/index.ts
2205-packages/db/src/index.ts
2206-packages/planner/src/index.ts
2207-packages/step-templates/src/index.ts
2208-packages/logging/src/index.ts
2209-packages/checkpointing/src/index.ts
2210-```
2211-
2212-## 27. mini 与 mac 上的 launchd
2213-
2214-因为 mini 和 mac 都是 macOS,推荐用 `launchd` 做进程守护。
2215-
2216-建议服务:
2217+当前只维护 `mini` 的 launchd 安装:
2218 
2219 - `so.makefile.baa-conductor`
2220-- `so.makefile.baa-worker-runner`
2221 - 可选 `so.makefile.baa-status-api`
2222+- 可选 `so.makefile.baa-worker-runner`
2223 
2224-建议行为:
2225-
2226-- 开机或登录自动启动
2227-- 失败自动拉起
2228-- 日志写文件
2229-
2230-conductor 的示例参数:
2231-
2232-```xml
2233-<array>
2234-  <string>/usr/bin/env</string>
2235-  <string>node</string>
2236-  <string>/Users/george/code/baa-conductor/apps/conductor-daemon/dist/index.js</string>
2237-  <string>--host</string>
2238-  <string>mini</string>
2239-  <string>--role</string>
2240-  <string>primary</string>
2241-</array>
2242-```
2243-
2244-## 27.1 建议的 launchd 路径
2245-
2246-推荐安装路径:
2247-
2248-- repo 中的 plist 源文件:`ops/launchd/*.plist`
2249-- 用户级安装路径:`~/Library/LaunchAgents/`
2250-- 如果需要开机登录前启动,可改用系统级 `/Library/LaunchDaemons/`
2251-
2252-推荐 label:
2253-
2254-- `so.makefile.baa-conductor`
2255-- `so.makefile.baa-worker-runner`
2256-- `so.makefile.baa-status-api`
2257-
2258-## 27.2 环境变量
2259-
2260-建议 conductor 与 worker 服务使用:
2261-
2262-```text
2263-BAA_CONDUCTOR_HOST=mini
2264-BAA_CONDUCTOR_ROLE=primary
2265-BAA_CONTROL_API_BASE=https://control-api.makefile.so
2266-BAA_CONDUCTOR_PUBLIC_BASE=https://conductor.makefile.so
2267-BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317
2268-BAA_CONDUCTOR_PRIVATE_BASE=http://100.71.210.78:4317
2269-BAA_CONDUCTOR_PEER_BASE=http://100.112.239.13:4317
2270-BAA_RUNS_DIR=/Users/george/code/baa-conductor/runs
2271-BAA_WORKTREES_DIR=/Users/george/code/baa-conductor/worktrees
2272-BAA_LOGS_DIR=/Users/george/code/baa-conductor/logs
2273-BAA_TMP_DIR=/Users/george/code/baa-conductor/tmp
2274-BAA_NODE_ID=mini-main
2275-BAA_SHARED_TOKEN=replace-me
2276-```
2277-
2278-mac 上至少需要不同的变量:
2279-
2280-- `BAA_CONDUCTOR_HOST`
2281-- `BAA_CONDUCTOR_ROLE`
2282-- `BAA_NODE_ID`
2283-- `BAA_CONDUCTOR_PRIVATE_BASE`
2284-- `BAA_CONDUCTOR_PEER_BASE`
2285-
2286-说明:
2287-
2288-- `BAA_CONTROL_API_BASE` 仍然指向 Cloudflare Worker,自身不走 Tailscale
2289-- 节点间与内网调用统一使用 `BAA_CONDUCTOR_PRIVATE_BASE` / `BAA_CONDUCTOR_PEER_BASE`
2290-- 不要把 `mini.tail0125d.ts.net` 或 `mbp.tail0125d.ts.net` 写进运行配置
2291-
2292-## 28. 安全模型
2293-
2294-## 28.1 Control API
2295-
2296-- conductor 和 worker 调用必须鉴权
2297-- pause、resume、drain、promote 等操作需要更强的 admin 鉴权
2298-
2299-## 28.2 直连节点域名
2300-
2301-- `mini-conductor.makefile.so` 和 `mac-conductor.makefile.so` 必须受保护
2302-- 使用 Basic Auth、IP allowlist 或两者同时使用
2303-
2304-## 28.3 日志
2305-
2306-日志中可能包含:
2307-
2308-- prompts
2309-- diffs
2310-- 文件路径
2311-- 测试输出
2312-
2313-规则:
2314-
2315-- 尽可能做 secrets 脱敏
2316-- 不公开暴露原始日志
2317-
2318-## 29. 可观测性
2319-
2320-每个 conductor 应暴露:
2321-
2322-- `/healthz`
2323-- `/readyz`
2324-- `/rolez`
2325-- 如果后续需要,可加 `/metrics`
2326-
2327-有用的状态字段:
2328-
2329-- controller id
2330-- role
2331-- lease holder
2332-- lease expiry
2333-- active runs
2334-- queue depth
2335-- paused mode
2336-
2337-## 30. 故障场景
2338-
2339-## 30.1 Mini 在空闲状态下死亡
2340-
2341-- lease 过期
2342-- mac 抢到 lease
2343-- 队列继续运行
2344-
2345-## 30.2 Mini 在 Codex Step 中死亡
2346-
2347-- run lease 过期
2348-- mac 成为 leader
2349-- mac 读取最新 checkpoint
2350-- mac 回放 checkpoint 或直接重跑该 step
2351-
2352-## 30.3 Mac 在 Standby 状态下死亡
2353-
2354-- 不影响服务
2355-
2356-## 30.4 D1 暂时不可用
2357-
2358-leader 应:
2359-
2360-- 停止 claim 新工作
2361-- 在安全前提下继续监管本地正在运行的 worker
2362-- 持续尝试重连
2363-- 在控制面未确认前,不进行不安全写入
2364-
2365-## 30.5 浏览器 UI 失效
2366-
2367-- 自动化仍然继续
2368-- 只是人类控制面临时不可用
2369-
2370-## 30.6 VPS 死亡
2371-
2372-如果 VPS 掉了:
2373-
2374-- 公网入口失效
2375-- 公网域名无法再直接做运维操作
2376-- 只要 mini 与 mac 还能访问 `control-api.makefile.so`,内部调度仍可继续
2377-- 只要 tailnet 还通,运维仍可通过 Tailscale `100.x` 直达节点
2378-
2379-推荐处理:
2380-
2381-- 不要因为公网入口消失就主动停掉 worker 集群
2382-- 单独修复 VPS
2383-
2384-## 30.7 Cloudflare Worker 或 D1 退化
2385-
2386-如果 control API 不可用:
2387-
2388-- leaders 停止 claim 新工作
2389-- 已在本地运行的 steps 可以在安全前提下继续
2390-- 不应盲目提交不可逆的调度状态
2391-
2392-恢复规则:
2393-
2394-- control API 恢复后,conductors 要把本地 run 状态与 D1 重新对账
2395-
2396-## 31. 建议的 API 返回码
2397-
2398-- `200` 正常读取
2399-- `202` 接受异步创建
2400-- `409` 节点健康但不是 leader,不能执行写操作
2401-- `423` task 或 step 被 lease 锁住
2402-- `503` 控制平面退化
2403-
2404-## 32. 建议的初始端口
2405-
2406-以下只是建议值:
2407-
2408-- conductor 本地 HTTP API: `4317`
2409-- status API: `4318`
2410-- 可选 metrics: `4319`
2411-
2412-## 33. 时序流程
2413-
2414-## 33.1 Task 创建流程
2415-
2416-1. human 在可见 Claude `control` 中下指令
2417-2. `control` 通过 control API 创建 task
2418-3. leader conductor 取到 task
2419-4. leader 选择模板或 planner
2420-5. 持久化 plan
2421-6. steps 进入 runnable
2422-
2423-## 33.2 Step 执行流程
2424-
2425-1. leader claim 下一个 runnable step
2426-2. leader 分配 worker 与 worktree
2427-3. worker 启动
2428-4. worker 把日志持续写到本地
2429-5. conductor 写 heartbeat 与 checkpoints
2430-6. worker 退出
2431-7. conductor 把 step 标记为 complete 或 failed
2432-
2433-## 33.3 Failover 流程
2434-
2435-1. mini lease 过期
2436-2. mac 获取 leadership
2437-3. mac 扫描未完成 steps
2438-4. mac 基于 checkpoints 恢复 runnable 工作
2439-
2440-## 33.4 人工 Pause 与接管流程
2441-
2442-1. 人在 Firefox 插件或状态页点击 `Pause`
2443-2. 浏览器调用 `POST /v1/system/pause`
2444-3. control API 更新 D1 中的 `system_state`
2445-4. 当前 leader 观察到 mode 变化
2446-5. leader 停止启动新的 steps
2447-6. 人开始和可见 Claude `control` 对话
2448-7. 人点击 `Resume`
2449-8. leader 恢复调度
2450-
2451-## 34. 建议的 MVP 范围
2452-
2453-第一阶段至少要包含:
2454-
2455-1. 仓库骨架
2456-2. D1 schema 与 migration scripts
2457-3. control API Worker
2458-4. conductor lease loop
2459-5. task 与 step 持久化
2460-6. 一个带本地日志流的 Codex step runner
2461-7. 一个 shell step runner
2462-8. pause、resume、drain
2463-9. mini -> mac 的基础 failover
2464-10. Firefox 插件按钮接到 control API
2465-
2466-## 35. 建议的并行开发拆分
2467-
2468-这一节的目的就是让多个 Codex worker 立刻开始并行干活。
2469-
2470-每个 task 只能有一个分支和一个 owner。
2471-
2472-## 35.0 协作规则
2473-
2474-这些规则在多 Codex 并行时必须遵守。
2475-
2476-### 分支规则
2477-
2478-- 一个 task 等于一个 branch
2479-- 一个 branch 同时只允许一个 owner
2480-- branch 名由 conductor 或 task 创建方分配
2481-
2482-### Worktree 规则
2483-
2484-- 一个 task 等于一个 worktree
2485-- 不允许两个 worker 共用一个活跃 worktree
2486-
2487-### Write Scope 规则
2488-
2489-每个 task 都必须声明 `write_scope`。
2490-
2491-如果需要修改超出 `write_scope` 的文件:
2492-
2493-- 要么新建 task
2494-- 要么显式修订 scope
2495-
2496-### 全局真相规则
2497-
2498-- task 真相在 D1
2499-- 本地真相在 run 目录
2500-- 对话记录不是 task 真相
2501-
2502-### 热点文件规则
2503-
2504-以下内容应视为协调敏感文件:
2505-
2506-- 根级 lockfile
2507-- 全局 CI 配置
2508-- 共享 schema 文件
2509-- 一旦开始实现后,这份 `DESIGN.md` 本身
2510-
2511-对热点文件的修改应串行化,或提前明确指派。
2512-
2513-## 35.0.1 Task Card 模板
2514-
2515-未来仓库中,每个并行 task 都应该有一个任务卡,例如:
2516-
2517-```md
2518----
2519-task_id: T-004
2520-title: Conductor lease and heartbeat loop
2521-owner: codex-mini-01
2522-branch: feat/T-004-conductor-lease
2523-repo: /Users/george/code/baa-conductor
2524-base_ref: main
2525-write_scope:
2526-  - apps/conductor-daemon/**
2527-  - packages/db/**
2528-depends_on:
2529-  - T-002
2530-  - T-003
2531-acceptance:
2532-  - leader lease acquisition works
2533-  - standby nodes do not schedule
2534-  - lease renewal tests added
2535-status: in_progress
2536----
2537-```
2538-
2539-## 35.0.2 完成报告模板
2540-
2541-task 完成后,worker 应至少回报:
2542-
2543-```md
2544-task_id: T-004
2545-branch: feat/T-004-conductor-lease
2546-base_ref: <commit>
2547-files_changed:
2548-  - apps/conductor-daemon/src/index.ts
2549-  - packages/db/src/lease.ts
2550-commands_run:
2551-  - pnpm test
2552-result:
2553-  - passed targeted tests
2554-risks:
2555-  - none
2556-```
2557-
2558-## 35.1 Task A: 仓库骨架
2559-
2560-Branch:
2561-
2562-- `feat/T-001-repo-scaffold`
2563-
2564-Scope:
2565-
2566-- 仓库目录结构
2567-- package manager 配置
2568-- 根配置
2569-- 初始 README
2570-
2571-不要改:
2572-
2573-- D1 SQL
2574-- Nginx 配置
2575-- Firefox 插件
2576-
2577-## 35.2 Task B: D1 Schema 与 Migrations
2578-
2579-Branch:
2580-
2581-- `feat/T-002-d1-schema`
2582-
2583-Scope:
2584-
2585-- `ops/sql/**`
2586-- `packages/db/**`
2587-
2588-不要改:
2589-
2590-- conductor runtime
2591-- worker runner
2592-
2593-## 35.3 Task C: Control API Worker
2594-
2595-Branch:
2596-
2597-- `feat/T-003-control-api`
2598-
2599-Scope:
2600-
2601-- `apps/control-api-worker/**`
2602-
2603-依赖:
2604-
2605-- Task B
2606-
2607-## 35.4 Task D: Conductor Lease 与 Heartbeat Loop
2608-
2609-Branch:
2610-
2611-- `feat/T-004-conductor-lease`
2612-
2613-Scope:
2614-
2615-- `apps/conductor-daemon/**`
2616-- `packages/db/**`
2617-
2618-依赖:
2619-
2620-- Task B
2621-- Task C
2622-
2623-## 35.5 Task E: Worker Runner 与本地日志流
2624-
2625-Branch:
2626-
2627-- `feat/T-005-worker-runner`
2628-
2629-Scope:
2630-
2631-- `apps/worker-runner/**`
2632-- `packages/logging/**`
2633-
2634-不要改:
2635-
2636-- Nginx
2637-- Firefox 插件
2638-
2639-## 35.6 Task F: Checkpoint 与 Git Diff Snapshots
2640-
2641-Branch:
2642-
2643-- `feat/T-006-checkpointing`
2644-
2645-Scope:
2646-
2647-- `packages/checkpointing/**`
2648-- worker-runner 集成
2649-
2650-依赖:
2651-
2652-- Task E
2653-
2654-## 35.7 Task G: Planner 抽象与模板
2655-
2656-Branch:
2657-
2658-- `feat/T-007-planner`
2659-
2660-Scope:
2661-
2662-- `packages/planner/**`
2663-- `packages/step-templates/**`
2664-
2665-## 35.8 Task H: Nginx 与 VPS 运维
2666-
2667-Branch:
2668-
2669-- `feat/T-008-ops-nginx`
2670-
2671-Scope:
2672-
2673-- `ops/nginx/**`
2674-- 部署文档
2675-
2676-## 35.9 Task I: Firefox 插件 Pause 与 Resume
2677-
2678-Branch:
2679-
2680-- `feat/T-009-firefox-pause`
2681-
2682-Scope:
2683-
2684-- Firefox 插件集成文档
2685-- 浏览器状态协议
2686-
2687-这部分现在统一收口在本仓库的 Firefox 插件子目录里,但协议仍然在这里定义清楚。
2688-
2689-## 35.10 Task J: Status API 与基础 UI
2690-
2691-Branch:
2692-
2693-- `feat/T-010-status-api`
2694-
2695-Scope:
2696-
2697-- `apps/status-api/**`
2698-
2699-## 35.11 Task K: launchd 与本地 Runtime 布局
2700-
2701-Branch:
2702-
2703-- `feat/T-011-launchd-runtime`
2704-
2705-Scope:
2706-
2707-- `ops/launchd/**`
2708-- 本地 runtime 路径文档
2709-
2710-## 35.12 Task L: 鉴权与 Token 模型
2711-
2712-Branch:
2713-
2714-- `feat/T-012-auth-model`
2715-
2716-Scope:
2717-
2718-- worker 鉴权设计
2719-- token 校验中间件
2720-- 角色定义
2721-
2722-## 36. Runbook
2723-
2724-这一节故意写成运维操作说明,是真正出问题时要照着用的。
2725-
2726-## 36.1 初始部署 Runbook
2727-
2728-1. 部署 Cloudflare Worker 与 D1
2729-2. 创建 D1 schema
2730-3. 部署 `control-api.makefile.so`
2731-4. 配置 DNS 记录
2732-5. 在 VPS 安装 Nginx 配置
2733-6. 验证 `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so`
2734-7. 在 mini 安装 launchd 服务
2735-8. 在 mac 安装 launchd 服务
2736-9. 启动 mini conductor 并确认拿到 lease
2737-10. 启动 mac conductor 并确认处于 standby
2738-11. 验证浏览器 `Pause` 与 `Resume`
2739-
2740-## 36.2 计划内切换 Runbook
2741+脚本:
2742 
2743-从 mini 安全切到 mac:
2744+- [`scripts/runtime/install-launchd.sh`](./scripts/runtime/install-launchd.sh)
2745+- [`scripts/runtime/check-launchd.sh`](./scripts/runtime/check-launchd.sh)
2746+- [`scripts/runtime/check-node.sh`](./scripts/runtime/check-node.sh)
2747+- [`scripts/runtime/reload-launchd.sh`](./scripts/runtime/reload-launchd.sh)
2748 
2749-1. 把全局模式设为 `draining`
2750-2. 等活跃 runs 正常结束或至少完成 checkpoint
2751-3. 确认 mini 没有未收口的可变工作
2752-4. demote mini
2753-5. promote mac,或让 mac 自然获取 lease
2754-6. 确认 mac 的 `GET /rolez` 返回 `leader`
2755-7. 把全局模式改回 `running`
2756+## 7. Nginx 与 DNS
2757 
2758-## 36.3 紧急故障切换 Runbook
2759+当前只维护一个公网入口:
2760 
2761-如果 mini 突然挂掉:
2762+- `conductor.makefile.so`
2763 
2764-1. 确认 mini heartbeat 已过期
2765-2. 等 lease 超时
2766-3. 确认 mac 已获取 lease
2767-4. 查看未完成 runs
2768-5. 基于 checkpoints 恢复或重跑当前 steps
2769+回源关系:
2770 
2771-## 36.4 切回 Mini 的 Runbook
2772+- `conductor.makefile.so` -> VPS Nginx -> `100.71.210.78:4317`
2773 
2774-mini 恢复后:
2775+相关文件:
2776 
2777-1. 先让 mini 继续保持 standby
2778-2. 修复原始故障
2779-3. 选择一个低峰窗口
2780-4. 把系统设为 `draining`
2781-5. drain mac
2782-6. promote mini
2783-7. 确认 mini 成为 leader
2784-8. 把系统切回 `running`
2785+- [`ops/nginx/baa-conductor.conf`](./ops/nginx/baa-conductor.conf)
2786+- [`scripts/ops/baa-conductor.env.example`](./scripts/ops/baa-conductor.env.example)
2787+- [`scripts/ops/cloudflare-dns-plan.mjs`](./scripts/ops/cloudflare-dns-plan.mjs)
2788+- [`scripts/ops/nginx-sync-plan.mjs`](./scripts/ops/nginx-sync-plan.mjs)
2789 
2790-## 37. 接下来立刻要做的事
2791+## 8. 保留的内部复杂度
2792 
2793-下一波开发建议按这个顺序推进:
2794+当前主线仍保留一部分内部 `leader` / `lease` 语义,因为:
2795 
2796-1. 创建代码骨架
2797-2. 实现 D1 schema
2798-3. 实现 control API
2799-4. 实现 conductor lease
2800-5. 实现一个 worker runner
2801-6. 实现 checkpoints
2802-7. 接上 Firefox 的 pause 与 resume
2803-8. 补上 VPS Nginx 配置
2804+- control plane 已经围绕该模型实现
2805+- 单节点下它仍能正常工作
2806+- 现在不值得为替代项目到来前再做一次大规模重构
2807 
2808-做到这里,这套系统就能真正支撑多 Codex 并行执行。
2809+但这些内部语义不再代表“主备切换能力是当前目标”。
2810 
2811-## 38. 总结
2812+## 9. 当前真正关心的事情
2813 
2814-这份设计刻意把以下东西拆开:
2815+- `mini` 本地服务可持续启动
2816+- `control-api` 与 `conductor` 对外可达
2817+- 浏览器插件可以控制系统模式
2818+- `status-api` 的真相来源逐步收口
2819 
2820-- 人类对话
2821-- 自动化对话
2822-- 规划
2823-- 编排
2824-- 执行
2825+## 10. 历史回溯
2826 
2827-durable 核心是:
2828+需要查看旧的主备设计、切换脚本和历史文档时:
2829 
2830-- D1 存共享真相
2831-- conductor 做确定性编排
2832-- Codex 做短生命周期 step worker
2833-- 本地日志加周期性 checkpoint
2834-- mini 做首选 leader,mac 做 standby
2835+- git tag: `ha-failover-archive-2026-03-22`
2836 
2837-后续所有实现都应该以这个模型为准。
2838+当前主线不再保留这些内容。
M README.md
+23, -36
 1@@ -1,23 +1,22 @@
 2 # baa-conductor
 3 
 4-`baa-conductor` 是新的 AI 执行编排仓库。
 5+`baa-conductor` 现在是一个收口后的单节点执行控制仓库。
 6 
 7-它的目标不是提供单个 AI 会话,而是提供一套以 `mini` 为唯一中控、带自启动和浏览器控制面的稳定执行基础设施。
 8+当前目标只有一个:
 9 
10-当前仓库状态:
11+- `mini` 作为唯一中控长期运行
12+- `mini` 负责自启动、控制面、状态面和浏览器插件配合
13+- 对外公网入口保留 `control-api.makefile.so` 与 `conductor.makefile.so`
14+- 内网回源固定走 `mini` 的 Tailscale `100.x`
15 
16-- 设计文档已落在 [`DESIGN.md`](./DESIGN.md)
17-- 代码目录骨架已建立
18-- 当前推荐部署模式已经收口为 `mini` 单节点
19-- 并行任务文档已放在 [`coordination/`](./coordination/)
20+主备切换、failover、switchback 和其它历史方案已经从当前主线移除;如需回溯,直接查看 tag `ha-failover-archive-2026-03-22`。
21 
22 ## 先读什么
23 
24-每个 Codex 实例启动后,按这个顺序读:
25-
26 1. [`DESIGN.md`](./DESIGN.md)
27 2. [`coordination/TASK_OVERVIEW.md`](./coordination/TASK_OVERVIEW.md)
28-3. 自己的任务卡,例如 `coordination/tasks/T-004-conductor-lease.md`
29+3. [`docs/runtime/README.md`](./docs/runtime/README.md)
30+4. [`docs/ops/README.md`](./docs/ops/README.md)
31 
32 ## 当前目录结构
33 
34@@ -42,11 +41,13 @@ ops/
35   launchd/
36   nginx/
37   sql/
38+scripts/
39+  ops/
40+  runtime/
41 coordination/
42   STATUS_SUMMARY.md
43   TASK_OVERVIEW.md
44   WORKFLOW.md
45-  tasks/
46 docs/
47   auth/
48   decisions/
49@@ -57,32 +58,18 @@ docs/
50 
51 ## 当前约定
52 
53-- 当前只保留 `mini` 的 conductor / status-api / 自启动路径
54-- 不再继续推进 `mac` 备主与主备切换
55-- 一个任务一个分支
56-- 一个任务一个 worktree
57-- 一个任务一个任务卡
58-- 任务真相靠任务卡与 D1,不靠聊天记录
59-- 各 Codex 只更新自己的任务卡
60-- 汇总状态由整合者更新到 `coordination/STATUS_SUMMARY.md`
61-
62-## 现在可以做什么
63-
64-当前最适合的工作方式:
65-
66-1. 由整合者分配 `coordination/tasks/` 里的任务
67-2. 每个 Codex 建自己的分支与 worktree
68-3. 在声明好的 `write_scope` 内开发
69-4. 完成后更新任务卡中的状态、命令、风险和交付物
70-5. 由整合者统一汇总和集成
71+- 当前只维护 `mini` 单节点运行路径
72+- 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
73+- 历史主备资料不再在主线保留,靠 tag 回溯
74+- 当前仓库优先做运维、修补和最小必要的控制面维护
75 
76-## 非目标
77+## 当前最重要的事
78 
79-当前骨架阶段不追求:
80+- 保持 `mini` launchd、自启动和本地探针稳定
81+- 保持 `control-api.makefile.so` 与 `conductor.makefile.so` 可用
82+- 继续收口 `status-api` 的真实状态来源
83 
84-- 已完成的生产功能
85-- 完整测试覆盖
86-- 完整 control API
87-- 完整的 D1 接入
88+## 当前已知问题
89 
90-这些都将在后续并行任务中完成。
91+- live `status-api` 仍可能和 control plane 真相漂移
92+- runtime 目录和部署路径还需要继续 canonicalize
D coordination/FIFTH_WAVE_START.md
+0, -43
 1@@ -1,43 +0,0 @@
 2-# 第五波统一开工说明
 3-
 4-第五波任务统一要求如下:
 5-
 6-- 所有任务必须从当前 `origin/main` 最新提交切分支
 7-- 每个任务必须使用独立 worktree
 8-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
 9-- 不允许从其他任务分支继续切分支
10-- 开始时把任务卡状态改为 `in_progress`
11-- 完成并推送后把任务卡状态改为 `review`
12-
13-推荐命令模板:
14-
15-```bash
16-git fetch origin
17-git switch main
18-git pull --ff-only origin main
19-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
20-cd ../baa-conductor-TXXX
21-npx --yes pnpm install
22-```
23-
24-每个实例启动后按顺序读:
25-
26-1. `DESIGN.md`
27-2. `coordination/TASK_OVERVIEW.md`
28-3. `coordination/WORKFLOW.md`
29-4. `coordination/tasks/T-XXX-*.md`
30-
31-第五波的目标不是再补单点入口,而是把这些入口串起来,推进到真正可联调的阶段:
32-
33-- control-api 的本地 D1 / smoke
34-- 多服务端到端 smoke
35-- 主备 failover rehearsal
36-- `baa-firefox` 的实际接线
37-- 节点侧 launchd / on-node 验证
38-
39-注意:
40-
41-- 当前仍然采用“内网走 Tailscale `100.x`,外网走二级域名”
42-- 当前仍不依赖 MagicDNS 名称
43-- `README.md`、`DESIGN.md`、根配置文件仍视为热点文件,非任务必要不要碰
44-- `T-026` 在当时是跨仓库任务;当前 Firefox 插件代码已经收口到本仓库子目录 `plugins/baa-firefox/`
D coordination/FINAL_STAGE_START.md
+0, -43
 1@@ -1,43 +0,0 @@
 2-# 最后收口阶段开工说明
 3-
 4-当前不再继续按“大波次”拆分。
 5-
 6-后续工作聚焦两个真实环境任务:
 7-
 8-- `T-028` 真实 Cloudflare / VPS 上线执行
 9-- `T-029` 多节点长时间稳定性回归
10-
11-统一要求:
12-
13-- 所有任务必须从当前 `origin/main` 最新提交切分支
14-- 每个任务必须使用独立 worktree
15-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
16-- 不允许从其他任务分支继续切分支
17-- 开始时把任务卡状态改为 `in_progress`
18-- 完成并推送后把任务卡状态改为 `review`
19-
20-推荐命令模板:
21-
22-```bash
23-git fetch origin
24-git switch main
25-git pull --ff-only origin main
26-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
27-cd ../baa-conductor-TXXX
28-npx --yes pnpm install
29-```
30-
31-每个实例启动后按顺序读:
32-
33-1. `DESIGN.md`
34-2. `coordination/TASK_OVERVIEW.md`
35-3. `coordination/WORKFLOW.md`
36-4. `coordination/tasks/T-XXX-*.md`
37-
38-注意:
39-
40-- 当前仍然采用“内网走 Tailscale `100.x`,外网走二级域名”
41-- 当前仍不依赖 MagicDNS 名称
42-- `README.md`、`DESIGN.md`、根配置文件仍视为热点文件,非任务必要不要碰
43-- `T-028` 允许执行真实 Cloudflare / VPS / launchd 操作,但必须把实际变更、执行命令和结果写回任务卡
44-- `T-029` 默认基于 `T-028` 已完成的环境执行;如果 `T-028` 未结束,可先准备脚本和观察项,但不要伪造真实回归结果
D coordination/FOURTH_WAVE_START.md
+0, -47
 1@@ -1,47 +0,0 @@
 2-# 第四波统一开工说明
 3-
 4-状态:
 5-
 6-- 第四波任务 `T-018` 到 `T-022` 已全部完成并合入 `main`
 7-- 本文件保留作第四波启动记录
 8-
 9-第四波任务统一要求如下:
10-
11-- 所有任务必须从当前 `origin/main` 最新提交切分支
12-- 每个任务必须使用独立 worktree
13-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
14-- 不允许从其他任务分支继续切分支
15-- 开始时把任务卡状态改为 `in_progress`
16-- 完成并推送后把任务卡状态改为 `review`
17-
18-推荐命令模板:
19-
20-```bash
21-git fetch origin
22-git switch main
23-git pull --ff-only origin main
24-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
25-cd ../baa-conductor-TXXX
26-npx --yes pnpm install
27-```
28-
29-每个实例启动后按顺序读:
30-
31-1. `DESIGN.md`
32-2. `coordination/TASK_OVERVIEW.md`
33-3. `coordination/WORKFLOW.md`
34-4. `coordination/tasks/T-XXX-*.md`
35-
36-第四波的目标不是继续补纯骨架,而是把当前主线推进到“可部署、可探活、可自动化运维”的阶段:
37-
38-- Cloudflare Worker / D1 绑定配置
39-- conductor 本地 HTTP 探活入口
40-- status-api 宿主进程
41-- launchd bootstrap 脚本
42-- Nginx 与 Cloudflare DNS 自动化
43-
44-注意:
45-
46-- 这一波仍然保持“内网走 Tailscale `100.x`,外网走二级域名”
47-- 这一波不依赖 MagicDNS 名称
48-- `README.md`、`DESIGN.md`、根配置文件默认仍视为热点文件,非任务必要不要碰
D coordination/SECOND_WAVE_START.md
+0, -34
 1@@ -1,34 +0,0 @@
 2-# 第二波统一开工说明
 3-
 4-这份说明已完成历史使命。
 5-
 6-第二波任务已经全部合入 `main`。
 7-
 8-后续如需继续并行开发,请基于新的 `main` 提交重新生成第三波任务卡与对应启动说明。
 9-
10-第二波任务统一要求如下:
11-
12-- 所有任务必须从 `main@28829de` 切分支
13-- 每个任务使用独立 worktree
14-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
15-- 不允许从其他任务分支继续切分支
16-- 开始时把任务卡状态改为 `in_progress`
17-- 完成并推送后把任务卡状态改为 `review`
18-
19-推荐命令模板:
20-
21-```bash
22-git fetch origin
23-git switch main
24-git pull --ff-only origin main
25-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
26-cd ../baa-conductor-TXXX
27-npx --yes pnpm install
28-```
29-
30-每个实例启动后按顺序读:
31-
32-1. `DESIGN.md`
33-2. `coordination/TASK_OVERVIEW.md`
34-3. `coordination/WORKFLOW.md`
35-4. `coordination/tasks/T-XXX-*.md`
M coordination/STATUS_SUMMARY.md
+20, -68
  1@@ -1,81 +1,33 @@
  2 # 全局状态汇总
  3 
  4-由整合者维护。
  5-
  6 ## 当前时间
  7 
  8 - `2026-03-22`
  9 
 10-## 总览
 11-
 12-- `done`: 30
 13-- `in_progress`: 0
 14-- `blocked`: 0
 15-- `todo`: 0
 16-
 17-## 已完成
 18-
 19-- `T-001` 仓库骨架
 20-- `T-002` D1 Schema 与 Migrations
 21-- `T-003` Control API Worker
 22-- `T-004` Conductor Lease 与 Heartbeat
 23-- `T-005` Worker Runner 与本地日志流
 24-- `T-006` Checkpoint 与 Git Diff Snapshots
 25-- `T-007` Planner 抽象与模板
 26-- `T-008` Nginx 与 VPS 运维
 27-- `T-009` Firefox 插件 Pause/Resume 协议
 28-- `T-010` Status API 与基础 UI
 29-- `T-011` launchd 与本地 Runtime 布局
 30-- `T-012` 鉴权与 Token 模型
 31-- `T-013` Build 与 dist 产物
 32-- `T-014` Control API 运行时接线
 33-- `T-015` Conductor 运行时接线
 34-- `T-016` Worker 本地持久化
 35-- `T-017` Status API 运行时入口
 36-- `T-018` Cloudflare Worker 与 D1 部署配置
 37-- `T-019` Conductor 本地 HTTP 入口
 38-- `T-020` Status API 本地宿主进程
 39-- `T-021` launchd 安装脚本与 Runtime Bootstrap
 40-- `T-022` Nginx 与 Cloudflare DNS 自动化
 41-- `T-023` Control API 本地 D1 与 smoke
 42-- `T-024` 端到端 smoke harness
 43-- `T-025` Failover rehearsal 与 Runbook
 44-- `T-026` `baa-firefox` 实际接线
 45-- `T-027` launchd 节点验证与 On-Node 检查
 46-- `T-028A` T-028 Tailscale 监听解阻
 47-- `T-028` 真实 Cloudflare / VPS 上线执行
 48-- `T-029` 多节点长时间稳定性回归
 49-
 50-这些任务卡已归档到 `coordination/tasks/done/`。
 51-
 52-## 当前活动任务
 53-
 54-- 无
 55-
 56-## 下一步建议
 57-
 58-- 当前没有活动开发任务。
 59-- 后续以 `mini` 单节点运维、观察和零散修复为主。
 60+## 当前状态
 61 
 62-## 需要整合者关注的点
 63+- 部署目标:`mini` 单节点
 64+- 当前没有活动开发任务
 65+- 历史主备资料已从主线移除
 66+- 回溯 tag:`ha-failover-archive-2026-03-22`
 67 
 68-- `README.md`、`DESIGN.md`、根配置文件应避免多人同时修改
 69-- `ops/launchd/**` 已合入,当前主线已经产出 app 级 `dist/index.js`
 70-- `apps/conductor-daemon/package.json` 与 `apps/status-api/package.json` 的构建脚本已在第三波整合时收口
 71-- Firefox 插件代码现已收口到本仓库子目录 `plugins/baa-firefox/`
 72-- 当前推荐部署模式已改成 `mini` 单节点,不再继续推进主备切换
 73+## 当前在线面
 74 
 75-## 后续汇总规则
 76+- `https://control-api.makefile.so`
 77+- `https://conductor.makefile.so`
 78+- `mini` 本地 conductor:`http://100.71.210.78:4317`
 79+- `mini` 本地 status-api:`http://100.71.210.78:4318`
 80 
 81-每次整合时,建议至少更新:
 82+## 当前保留内容
 83 
 84-- 已完成任务
 85-- 当前进行中任务
 86-- 阻塞原因
 87-- 下一波建议分发
 88+- Cloudflare Worker + D1 控制面
 89+- `mini` 的 conductor / status-api / worker-runner
 90+- launchd 安装与检查脚本
 91+- Firefox 插件子目录 `plugins/baa-firefox`
 92+- 单节点的 Nginx / DNS 计划脚本
 93 
 94-当前阶段不要求记录具体实例名,只看:
 95+## 当前仍需关注
 96 
 97-- 哪个 task 已开始
 98-- 哪个 task 已完成
 99-- 哪个 task 被阻塞
100+- live `status-api` 可能和 control plane 真相漂移
101+- runtime 路径仍值得继续 canonicalize
102+- 若继续维护,只做小范围修补,不再恢复主备方案
M coordination/TASK_OVERVIEW.md
+18, -91
  1@@ -1,103 +1,30 @@
  2 # 任务总览
  3 
  4-这是当前 `baa-conductor` 仓库的并行任务总览。
  5+当前主线已经不再按波次推进,也不再保留历史任务板。
  6 
  7-## 1. 当前仓库基线
  8+## 当前状态
  9 
 10-当前已经完成的基础工作:
 11+- 没有活动开发任务
 12+- 当前仓库只维护 `mini` 单节点路径
 13+- 历史主备与并行开发资料已由 tag `ha-failover-archive-2026-03-22` 承担回溯职责
 14 
 15-- 设计文档 [`../DESIGN.md`](../DESIGN.md) 已落地
 16-- 仓库目录骨架已建立
 17-- `apps/`、`packages/`、`ops/`、`coordination/` 基础占位代码与文件已创建
 18+## 当前建议
 19 
 20-这意味着:
 21+如果还要继续维护这个仓库,优先顺序只有三件事:
 22 
 23-- `T-001` 视为已完成
 24-- 目前 `T-001` 到 `T-029` 以及 `T-028A` 都已完成并合入 `main`
 25-- 其他任务可以在当前骨架上并行开展
 26-- 当前阶段不强制记录实例名,只要求各任务卡状态及时更新
 27+1. 修 `status-api` 与 control plane 真相不一致的问题
 28+2. 收口 `mini` 的 runtime 路径和自启动安装路径
 29+3. 只在必要时做最小运维修补
 30 
 31-## 2. 启动前必读
 32-
 33-每个 Codex 实例必须先读:
 34+## 现在该读什么
 35 
 36 1. [`../DESIGN.md`](../DESIGN.md)
 37-2. [`WORKFLOW.md`](./WORKFLOW.md)
 38-3. 自己的活动任务卡
 39-
 40-## 3. 已完成的第一波任务
 41-
 42-下面这些任务已经完成并合入 `main`:
 43-
 44-- `T-002` D1 Schema 与 Migrations
 45-- `T-005` Worker Runner 与本地日志流
 46-- `T-007` Planner 抽象与模板
 47-- `T-008` Nginx 与 VPS 运维
 48-- `T-011` launchd 与本地 Runtime 布局
 49-- `T-012` 鉴权与 Token 模型
 50-
 51-这些内容现在已经是第二波任务的基线。
 52-
 53-对应任务卡已归档到:
 54-
 55-- [`tasks/done/`](./tasks/done/README.md)
 56-
 57-## 4. 当前状态
 58-
 59-当前主线任务已经全部完成,现阶段没有活动任务。
 60-
 61-最后收口阶段要求见:
 62-
 63-- [`FINAL_STAGE_START.md`](./FINAL_STAGE_START.md)
 64-
 65-## 5. 下一步建议
 66-
 67-当前优先做的是日常运维与零散修复,不再继续按波次拆大任务。
 68-
 69-建议重点关注:
 70-
 71-- live `status-api` 持续漂移问题
 72-- `mini` runtime 路径 canonicalization
 73-- 把运维文档和脚本继续收口到 `mini` 单节点模式
 74-
 75-## 6. 已归档任务
 76-
 77-- `T-001` 仓库骨架
 78-- `T-002` D1 Schema 与 Migrations
 79-- `T-003` Control API Worker
 80-- `T-004` Conductor Lease 与 Heartbeat
 81-- `T-005` Worker Runner 与本地日志流
 82-- `T-006` Checkpoint 与 Git Diff Snapshots
 83-- `T-007` Planner 抽象与模板
 84-- `T-008` Nginx 与 VPS 运维
 85-- `T-009` Firefox 插件 Pause/Resume 协议
 86-- `T-010` Status API 与基础 UI
 87-- `T-011` launchd 与本地 Runtime 布局
 88-- `T-012` 鉴权与 Token 模型
 89-- `T-013` Build 与 dist 产物
 90-- `T-014` Control API 运行时接线
 91-- `T-015` Conductor 运行时接线
 92-- `T-016` Worker 本地持久化
 93-- `T-017` Status API 运行时入口
 94-- `T-018` Cloudflare Worker 与 D1 部署配置
 95-- `T-019` Conductor 本地 HTTP 入口
 96-- `T-020` Status API 本地宿主进程
 97-- `T-021` launchd 安装脚本与 Runtime Bootstrap
 98-- `T-022` Nginx 与 Cloudflare DNS 自动化
 99-- `T-023` Control API 本地 D1 与 smoke
100-- `T-024` 端到端 smoke harness
101-- `T-025` Failover rehearsal 与 Runbook
102-- `T-026` `baa-firefox` 实际接线
103-- `T-027` launchd 节点验证与 On-Node 检查
104-- `T-028A` T-028 Tailscale 监听解阻
105-- `T-028` 真实 Cloudflare / VPS 上线执行
106-- `T-029` 多节点长时间稳定性回归
107+2. [`../docs/runtime/README.md`](../docs/runtime/README.md)
108+3. [`../docs/ops/README.md`](../docs/ops/README.md)
109+4. [`STATUS_SUMMARY.md`](./STATUS_SUMMARY.md)
110 
111-## 7. 汇总方式
112+## 如需新任务
113 
114-- 每个任务的详细状态在对应任务卡中
115-- 全局汇总在 [`STATUS_SUMMARY.md`](./STATUS_SUMMARY.md)
116-- 如需新任务,按现有模板新增到 `coordination/tasks/`
117-- 开始做任务时,Codex 自己把任务卡改成 `in_progress`
118-- 做完并推送后,Codex 自己把任务卡改成 `review` 或 `done`
119-- 已合入 `main` 的任务卡会被移到 `coordination/tasks/done/`
120+- 直接在 `coordination/tasks/` 新建任务卡
121+- 不再恢复旧 wave 文档
122+- 历史任务内容靠 tag 回溯,不在当前主线保留
D coordination/THIRD_WAVE_START.md
+0, -41
 1@@ -1,41 +0,0 @@
 2-# 第三波统一开工说明
 3-
 4-状态:
 5-
 6-- 第三波任务 `T-013` 到 `T-017` 已全部完成并合入 `main`
 7-- 本文件保留作第三波启动记录
 8-
 9-第三波任务统一要求如下:
10-
11-- 所有任务必须从当前 `origin/main` 最新提交切分支
12-- 每个任务必须使用独立 worktree
13-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
14-- 不允许从其他任务分支继续切分支
15-- 开始时把任务卡状态改为 `in_progress`
16-- 完成并推送后把任务卡状态改为 `review`
17-
18-推荐命令模板:
19-
20-```bash
21-git fetch origin
22-git switch main
23-git pull --ff-only origin main
24-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
25-cd ../baa-conductor-TXXX
26-npx --yes pnpm install
27-```
28-
29-每个实例启动后按顺序读:
30-
31-1. `DESIGN.md`
32-2. `coordination/TASK_OVERVIEW.md`
33-3. `coordination/WORKFLOW.md`
34-4. `coordination/tasks/T-XXX-*.md`
35-
36-第三波的目标不是继续铺骨架,而是把关键运行链路接实:
37-
38-- 真实 `build` 产物
39-- control-api 运行时接线
40-- conductor 运行时接线
41-- worker 本地持久化
42-- status API 真正可挂载的入口
M coordination/WORKFLOW.md
+3, -3
 1@@ -1,6 +1,6 @@
 2 # 协作工作流
 3 
 4-这份文档定义多个 Codex 实例同时开发时的最小协作规则。
 5+这份文档保留最小协作规则,供以后需要临时并行开发时复用。
 6 
 7 ## 1. 每个实例启动后要读什么
 8 
 9@@ -8,7 +8,7 @@
10 
11 1. [`../DESIGN.md`](../DESIGN.md)
12 2. [`TASK_OVERVIEW.md`](./TASK_OVERVIEW.md)
13-3. 自己的活动任务卡,例如 `tasks/T-004-conductor-lease.md`
14+3. 自己的活动任务卡
15 
16 ## 2. 每个实例开始前要做什么
17 
18@@ -51,7 +51,7 @@
19 - 各个 worker 只更新自己的任务卡
20 - 全局状态板由整合者更新
21 - 如果出现冲突,以任务卡和代码为准
22-- 已合入 `main` 的任务卡会被移到 `coordination/tasks/done/`
23+- 当前主线不再保留历史任务归档;需要历史上下文时,直接查 git tag
24 
25 ## 6. 推荐的分支与 worktree 模式
26 
D coordination/tasks/done/README.md
+0, -14
 1@@ -1,14 +0,0 @@
 2-# 已完成任务归档
 3-
 4-这个目录用于存放已经合入 `main` 的任务卡。
 5-
 6-规则:
 7-
 8-- 已完成并合入 `main` 的任务卡从 `coordination/tasks/` 移到这里
 9-- 活动任务区只保留:
10-  - 未开始任务
11-  - 进行中任务
12-  - 阻塞任务
13-  - 等待 review 的任务
14-
15-当前已归档的是第一波、第二波、第三波、第四波与第五波任务。
D coordination/tasks/done/T-001-repo-scaffold.md
+0, -87
 1@@ -1,87 +0,0 @@
 2----
 3-task_id: T-001
 4-title: 仓库骨架
 5-status: done
 6-branch: feat/T-001-repo-scaffold
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on: []
10-write_scope:
11-  - .gitignore
12-  - README.md
13-  - package.json
14-  - pnpm-workspace.yaml
15-  - tsconfig.base.json
16-  - apps/**
17-  - packages/**
18-  - ops/**
19-  - coordination/**
20-  - docs/**
21-updated_at: 2026-03-21
22----
23-
24-# T-001 仓库骨架
25-
26-## 目标
27-
28-建立 `baa-conductor` 的基础目录、根配置、占位代码与协作文档结构,让后续多个任务可以并行展开。
29-
30-## 本任务包含
31-
32-- 创建根级工作区配置
33-- 创建 `apps/`、`packages/`、`ops/`、`docs/`、`coordination/` 基础目录
34-- 创建最小占位代码文件
35-- 创建任务总览与协作规则
36-
37-## 本任务不包含
38-
39-- 真实 D1 逻辑
40-- 真实 conductor 调度逻辑
41-- 真实 worker 执行逻辑
42-- 真实 Nginx 部署配置
43-
44-## 建议起始文件
45-
46-- 已完成
47-
48-## 交付物
49-
50-- 初始仓库骨架
51-- 协作任务体系
52-
53-## 验收
54-
55-- 目录结构完整
56-- 协作文档可读
57-- 后续任务可以直接接着写
58-
59-## files_changed
60-
61-- 根配置文件
62-- `apps/**`
63-- `packages/**`
64-- `ops/**`
65-- `coordination/**`
66-- `docs/decisions/README.md`
67-
68-## commands_run
69-
70-- 未执行功能构建
71-
72-## result
73-
74-- 骨架已建立,可供并行任务继续开发
75-
76-## risks
77-
78-- 目前大部分代码是占位实现
79-
80-## next_handoff
81-
82-- 开始分发第一波并行任务
83-
84-## notes
85-
86-- `2026-03-21`: 初始化新仓库
87-- `2026-03-21`: 写入设计文档
88-- `2026-03-21`: 创建代码骨架与协作文档结构
D coordination/tasks/done/T-002-d1-schema.md
+0, -94
 1@@ -1,94 +0,0 @@
 2----
 3-task_id: T-002
 4-title: D1 Schema 与 Migrations
 5-status: done
 6-branch: feat/T-002-d1-schema
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - ops/sql/**
13-  - packages/db/**
14-updated_at: 2026-03-21
15----
16-
17-# T-002 D1 Schema 与 Migrations
18-
19-## 目标
20-
21-把 `DESIGN.md` 第 10 节中的 D1 schema 真正落成可执行 SQL,并建立 migration 约定与基础数据库访问层。
22-
23-## 本任务包含
24-
25-- 完成 `ops/sql/schema.sql`
26-- 完成 `ops/sql/migrations/0001_init.sql`
27-- 在 `packages/db/` 建立最小数据库访问抽象
28-- 明确表、索引、初始化状态写法
29-
30-## 本任务不包含
31-
32-- Cloudflare Worker 路由逻辑
33-- conductor lease loop
34-- worker runtime
35-
36-## 建议起始文件
37-
38-- `ops/sql/schema.sql`
39-- `ops/sql/migrations/0001_init.sql`
40-- `packages/db/src/index.ts`
41-- `DESIGN.md`
42-
43-## 交付物
44-
45-- 完整 schema SQL
46-- 初始 migration
47-- `packages/db` 的最小接口和模型定义
48-
49-## 验收
50-
51-- 包含 `leader_lease`
52-- 包含 `controllers`
53-- 包含 `workers`
54-- 包含 `tasks`
55-- 包含 `task_steps`
56-- 包含 `task_runs`
57-- 包含 `task_checkpoints`
58-- 包含 `task_logs`
59-- 包含 `system_state`
60-- 包含 `task_artifacts`
61-
62-## files_changed
63-
64-- `coordination/tasks/T-002-d1-schema.md`
65-- `ops/sql/schema.sql`
66-- `ops/sql/migrations/0001_init.sql`
67-- `packages/db/src/index.ts`
68-
69-## commands_run
70-
71-- `sqlite3 ':memory:' < /Users/george/code/baa-conductor/ops/sql/schema.sql`
72-- `sqlite3 ':memory:' < /Users/george/code/baa-conductor/ops/sql/migrations/0001_init.sql`
73-- `sqlite3 ':memory:' ".read /Users/george/code/baa-conductor/ops/sql/schema.sql" "SELECT state_key || ':' || value_json FROM system_state;"`
74-- `/Users/george/code/baa-conductor/node_modules/.bin/tsc --noEmit -p /Users/george/code/baa-conductor/packages/db/tsconfig.json`
75-- `git diff --check -- /Users/george/code/baa-conductor/ops/sql/schema.sql /Users/george/code/baa-conductor/ops/sql/migrations/0001_init.sql /Users/george/code/baa-conductor/packages/db/src/index.ts /Users/george/code/baa-conductor/coordination/tasks/T-002-d1-schema.md`
76-
77-## result
78-
79-- 按 `DESIGN.md` 第 10 节完成了 10 张 D1 表的可执行 schema 与首个 migration,并补上基础外键、恢复/查询需要的索引、数值约束,以及 `system_state` 中默认的 `automation=running` 初始化行。
80-- `packages/db/src/index.ts` 现提供完整表模型、D1 兼容接口、snake_case 到 camelCase 的 row mapper、常用 SQL 语句,以及面向 control plane 的低层 repository 骨架。
81-
82-## risks
83-
84-- `leader_lease` 目前只提供低层 upsert 读写,真正的抢锁/CAS 续租语义仍需在 `T-004` 中基于当前 schema 实现。
85-- `controllers`、`workers`、`task_runs` 的状态值在设计文档中尚未完全收敛,因此当前模型保持 `string`,后续任务需要在业务层补齐约束或进一步收紧类型。
86-- 本次 SQL 只在本地 SQLite 语义下做了烟测;接入真实 Cloudflare D1 binding 后仍应补一次端到端迁移与读写验证。
87-
88-## next_handoff
89-
90-- `T-003` 可以直接复用 `D1ControlPlaneRepository` 的 `system_state`、controller/worker heartbeat、task/task_step/task_log 持久化骨架来接 control API。
91-- `T-004` 可以在当前 schema/index 基线上补 `leader_lease` 原子获取/续租逻辑,以及 runnable step claim 查询。
92-
93-## notes
94-
95-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-003-control-api.md
+0, -91
 1@@ -1,91 +0,0 @@
 2----
 3-task_id: T-003
 4-title: Control API Worker
 5-status: done
 6-branch: feat/T-003-control-api
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@56e9a4f
 9-depends_on:
10-  - T-002
11-write_scope:
12-  - apps/control-api-worker/**
13-updated_at: 2026-03-21
14----
15-
16-# T-003 Control API Worker
17-
18-## 目标
19-
20-实现 Cloudflare Worker 风格的 control API 骨架,把设计中的关键接口落成代码结构。
21-
22-## 统一开工要求
23-
24-- 必须从 `main@56e9a4f` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 明确路由注册方式
31-- 为核心接口建立 handler 骨架
32-- 整理 request/response schema
33-- 预留 D1 绑定与鉴权挂载点
34-
35-## 本任务不包含
36-
37-- 完整 lease 算法
38-- 真实 worker 调度
39-- 前端页面
40-
41-## 建议起始文件
42-
43-- `apps/control-api-worker/src/index.ts`
44-- `DESIGN.md` 第 11 节
45-
46-## 交付物
47-
48-- 可扩展的 Worker 路由骨架
49-- 核心接口清单与占位 handler
50-
51-## 验收
52-
53-- 至少包含 heartbeat、acquire、tasks、steps、system state 路由骨架
54-- 结构上便于后续接 D1
55-
56-## files_changed
57-
58-- `apps/control-api-worker/src/index.ts`
59-- `apps/control-api-worker/src/contracts.ts`
60-- `apps/control-api-worker/src/schemas.ts`
61-- `apps/control-api-worker/src/handlers.ts`
62-- `apps/control-api-worker/src/router.ts`
63-- `apps/control-api-worker/tsconfig.json`
64-- `coordination/tasks/T-003-control-api.md`
65-
66-## commands_run
67-
68-- `npx --yes pnpm install`
69-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
70-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
71-
72-## result
73-
74-- 已将 `control-api-worker` 重构为 Cloudflare Worker 风格入口,提供默认 `fetch` 导出、请求 ID、统一 JSON 错误包和基础 404/405/400 处理。
75-- 已注册设计第 11 节要求的核心路由骨架,覆盖 controller heartbeat、leader acquire、tasks、steps、system state,以及 task/log/run 查询接口。
76-- 已整理每条路由的 request/response schema 描述,并预留 D1 repository 注入与 Bearer token 鉴权挂载点,便于 `T-004` 与 `T-010` 继续接线。
77-
78-## risks
79-
80-- 所有业务 handler 目前仍返回 `501 not_implemented`,真实 lease、claim、checkpoint、task 读写逻辑尚未接入 D1。
81-- 当前只做 JSON 解析与授权挂点,没有做字段级 runtime 校验;后续接真实写路径时需要补请求校验或约束收口。
82-- 未注入 `tokenVerifier` 时会跳过实际鉴权,仅保留授权模型上下文;生产接入前必须补齐 verifier 装配。
83-
84-## next_handoff
85-
86-- `T-004` 可直接在现有 route registry 上接 leader/task/step 写路径,复用 ownership resolver、auth hook 和 repository 注入点。
87-- `T-010` 可直接复用 `GET /v1/system/state`、`GET /v1/tasks/:task_id`、`GET /v1/tasks/:task_id/logs`、`GET /v1/runs/:run_id` 的接口合同与统一响应结构。
88-
89-## notes
90-
91-- `2026-03-21`: 创建任务卡
92-- `2026-03-21`: 实际按指令从 `main@56e9a4f` 切出,任务卡已同步该基线。
D coordination/tasks/done/T-004-conductor-lease.md
+0, -91
 1@@ -1,91 +0,0 @@
 2----
 3-task_id: T-004
 4-title: Conductor Lease 与 Heartbeat
 5-status: done
 6-branch: feat/T-004-conductor-lease
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@56e9a4f
 9-depends_on:
10-  - T-002
11-  - T-003
12-write_scope:
13-  - apps/conductor-daemon/**
14-  - packages/db/**
15-updated_at: 2026-03-21
16----
17-
18-# T-004 Conductor Lease 与 Heartbeat
19-
20-## 目标
21-
22-实现 conductor 的主备基础行为:注册、heartbeat、lease 获取与续租、standby 拒绝调度。
23-
24-## 统一开工要求
25-
26-- 必须从 `main@56e9a4f` 切出该分支
27-- 新 worktree 进入后先执行 `npx --yes pnpm install`
28-- 不允许从其他任务分支切分支
29-
30-## 本任务包含
31-
32-- conductor startup checklist 落代码
33-- leader/standby 状态骨架
34-- heartbeat loop 骨架
35-- acquire/renew lease 调用路径
36-
37-## 本任务不包含
38-
39-- 完整 step 调度
40-- worker 实际执行
41-- checkpoint 恢复
42-
43-## 建议起始文件
44-
45-- `apps/conductor-daemon/src/index.ts`
46-- `packages/db/src/index.ts`
47-- `DESIGN.md` 第 8、16 节
48-
49-## 交付物
50-
51-- conductor 基础状态机骨架
52-- leader 与 standby 的最小分支逻辑
53-
54-## 验收
55-
56-- 能表达 leader/standby/degraded
57-- 能区分 acquire 与 renew
58-- 结构上支持后续 scheduler 接入
59-
60-## files_changed
61-
62-- `apps/conductor-daemon/src/index.ts`
63-- `apps/conductor-daemon/src/index.test.js`
64-- `packages/db/src/index.ts`
65-- `packages/db/src/index.test.js`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/db typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
72-- `node --test --experimental-strip-types packages/db/src/index.test.js`
73-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
74-
75-## result
76-
77-- `packages/db` 新增 controller heartbeat helper、lease acquire/renew 数据模型,以及基于 `leader_lease` 的最小抢占/续租仓储方法。
78-- `apps/conductor-daemon` 新增 startup checklist、control API client、heartbeat/lease loop、leader-only scheduler gate,并补了针对性的状态机测试。
79-
80-## risks
81-
82-- `apps/conductor-daemon` 当前内置了一份 lease/heartbeat payload 类型,后续适合和 `T-003` 的 control API 契约收敛到共享定义。
83-- 当前 lease 时间字段按 epoch seconds 处理,后续接入真实 control API 时需要和响应约定保持一致。
84-
85-## next_handoff
86-
87-- `T-003` 可直接把 `/v1/controllers/heartbeat` 与 `/v1/leader/acquire` 接到 `packages/db` 新增的 helper 与仓储方法。
88-- 后续 scheduler 与 status API 可直接复用 `ConductorDaemon.getStatusSnapshot()`、`canSchedule()`、`runSchedulerPass()` 作为主备门禁基线。
89-
90-## notes
91-
92-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-005-worker-runner.md
+0, -88
 1@@ -1,88 +0,0 @@
 2----
 3-task_id: T-005
 4-title: Worker Runner 与本地日志流
 5-status: done
 6-branch: feat/T-005-worker-runner
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - apps/worker-runner/**
13-  - packages/logging/**
14-updated_at: 2026-03-21T19:41:39+0800
15----
16-
17-# T-005 Worker Runner 与本地日志流
18-
19-## 目标
20-
21-建立 worker-runner 的最小执行框架,使其能围绕一个 step 管理本地日志、结果结构和执行骨架。
22-
23-## 本任务包含
24-
25-- `worker-runner` 的 step request/result 结构
26-- 本地 stdout/stderr/worker.log 组织方式
27-- logging 包中的基础数据结构
28-
29-## 本任务不包含
30-
31-- checkpoint diff 逻辑
32-- 真实 Codex 集成细节
33-- control API 上报
34-
35-## 建议起始文件
36-
37-- `apps/worker-runner/src/index.ts`
38-- `packages/logging/src/index.ts`
39-- `DESIGN.md` 第 17、19 节
40-
41-## 交付物
42-
43-- worker-runner 框架骨架
44-- logging 包基础结构
45-
46-## 验收
47-
48-- 能表达 step request/result
49-- 能表达本地日志目录与事件模型
50-
51-## files_changed
52-
53-- `apps/worker-runner/src/index.ts`
54-- `apps/worker-runner/src/contracts.ts`
55-- `apps/worker-runner/src/runner.ts`
56-- `apps/worker-runner/tsconfig.json`
57-- `packages/logging/src/index.ts`
58-- `packages/logging/src/contracts.ts`
59-- `packages/logging/src/paths.ts`
60-- `packages/logging/src/session.ts`
61-- `packages/logging/src/state.ts`
62-- `coordination/tasks/T-005-worker-runner.md`
63-
64-## commands_run
65-
66-- `npx -p typescript tsc --version`
67-- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/packages/logging/tsconfig.json`
68-- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/apps/worker-runner/tsconfig.json`
69-- `git -C /Users/george/code/baa-conductor-t005 diff --check`
70-
71-## result
72-
73-- 建立了 `packages/logging` 的本地 run 路径约定、`meta/state` 数据结构、生命周期事件模型,以及 `worker.log/stdout.log/stderr.log` 的内存态抽象。
74-- 建立了 `apps/worker-runner` 的 step request/result、prepared run、默认占位 executor 与生命周期编排,明确区分 `prepared/completed/failed/blocked` 结果。
75-- 为 `T-006` 预留了 checkpoint 序号与目录接入点,但没有实现 checkpoint diff 或任何 checkpoint 内容落盘。
76-
77-## risks
78-
79-- 当前实现只定义路径、事件和内存态会话;还没有真实文件写入,因此 `meta.json`、`state.json`、各类 log 仍未持久化。
80-- 当前 executor 仍是占位实现,没有接真实 Codex、Shell 或 Git worker,后续接入时还需要补真实进程生命周期与输出采集。
81-
82-## next_handoff
83-
84-- `T-006` 可以直接基于 `PreparedStepRun`、`StepCheckpointState` 和 `logPaths` 接入 checkpoint 目录写入与恢复逻辑。
85-- 后续 worker 执行接入时应复用当前 `StepExecutionResult` 和 logging session 结构,把真实 stdout/stderr/worker 事件落到同一套路径与结果模型。
86-
87-## notes
88-
89-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-006-checkpointing.md
+0, -91
 1@@ -1,91 +0,0 @@
 2----
 3-task_id: T-006
 4-title: Checkpoint 与 Git Diff Snapshots
 5-status: done
 6-branch: feat/T-006-checkpointing
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@56e9a4f
 9-depends_on:
10-  - T-005
11-write_scope:
12-  - packages/checkpointing/**
13-  - apps/worker-runner/**
14-updated_at: 2026-03-21
15----
16-
17-# T-006 Checkpoint 与 Git Diff Snapshots
18-
19-## 目标
20-
21-把设计中的 checkpoint 概念落到代码结构里,尤其是 `summary`、`git_diff`、`log_tail` 这些中途恢复能力。
22-
23-## 统一开工要求
24-
25-- 必须从 `main@56e9a4f` 切出该分支
26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
27-- 不允许从其他任务分支切分支
28-
29-## 本任务包含
30-
31-- checkpoint 类型定义
32-- checkpoint 文件命名
33-- worker-runner 的 checkpoint 接口预留
34-- git diff checkpoint 的骨架接口
35-
36-## 本任务不包含
37-
38-- 完整 Git 命令执行器
39-- 完整 D1 上报细节
40-
41-## 建议起始文件
42-
43-- `packages/checkpointing/src/index.ts`
44-- `apps/worker-runner/src/index.ts`
45-- `DESIGN.md` 第 20 节
46-
47-## 交付物
48-
49-- checkpoint 数据结构与最小接口
50-- worker-runner 中的 checkpoint 接口位置
51-
52-## 验收
53-
54-- 至少支持 `summary`、`git_diff`、`log_tail`
55-- 结构上支持未来的 patch 回放
56-
57-## files_changed
58-
59-- `packages/checkpointing/src/index.ts`
60-- `apps/worker-runner/src/contracts.ts`
61-- `apps/worker-runner/src/checkpoints.ts`
62-- `apps/worker-runner/src/runner.ts`
63-- `apps/worker-runner/src/index.ts`
64-- `apps/worker-runner/tsconfig.json`
65-- `coordination/tasks/T-006-checkpointing.md`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
72-- `npx --yes pnpm typecheck`
73-
74-## result
75-
76-- 已实现 `summary`、`git_diff`、`log_tail` 的 checkpoint 类型、序号管理、文件命名约定与渲染接口。
77-- 已为 `git_diff` 增加快照命令计划与未来 `git apply --binary` 回放提示,但未接入真实 Git 执行。
78-- `worker-runner` 现已默认初始化 checkpoint manager,准备阶段写入 `summary` checkpoint,结束阶段生成 `log_tail` checkpoint,并把状态/计划暴露给后续 executor 与恢复流程。
79-
80-## risks
81-
82-- 当前实现只落到类型、计划和内存态记录,尚未把 checkpoint 文件真正写入本地目录。
83-- `git_diff` 仍是骨架接口;真实 diff 捕获、patch 持久化与 D1 上报需要后续任务接入。
84-
85-## next_handoff
86-
87-- 把 checkpoint manager 接到真实 worker executor、Git 命令执行与本地/D1 持久化链路,为 failover 恢复提供可回放 diff。
88-
89-## notes
90-
91-- `2026-03-21`: 创建任务卡
92-- `2026-03-21`: 按最新协调要求将基线修正为 `main@56e9a4f`
D coordination/tasks/done/T-007-planner.md
+0, -79
 1@@ -1,79 +0,0 @@
 2----
 3-task_id: T-007
 4-title: Planner 抽象与模板
 5-status: done
 6-branch: feat/T-007-planner
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - packages/planner/**
13-  - packages/step-templates/**
14-updated_at: 2026-03-21
15----
16-
17-# T-007 Planner 抽象与模板
18-
19-## 目标
20-
21-把 `planner` 作为抽象角色落到代码骨架里,并把设计中的模板任务拆分成可复用的 step template。
22-
23-## 本任务包含
24-
25-- `Planner` 接口整理
26-- `ProposedPlan` 与 `ProposedStep` 数据结构
27-- `feature_impl`、`bugfix` 等模板的完善
28-
29-## 本任务不包含
30-
31-- 调真实 Claude 或 Codex
32-- conductor 的最终 plan 验收逻辑
33-
34-## 建议起始文件
35-
36-- `packages/planner/src/index.ts`
37-- `packages/step-templates/src/index.ts`
38-- `DESIGN.md` 第 14、15 节
39-
40-## 交付物
41-
42-- planner 抽象层
43-- step template 定义
44-
45-## 验收
46-
47-- 模板命名清晰
48-- step kind 与设计文档一致
49-- 后续可直接被 conductor 调用
50-
51-## files_changed
52-
53-- `coordination/tasks/T-007-planner.md`
54-- `packages/planner/src/index.ts`
55-- `packages/step-templates/src/index.ts`
56-
57-## commands_run
58-
59-- `./node_modules/.bin/tsc --noEmit -p packages/planner/tsconfig.json`
60-- `./node_modules/.bin/tsc --noEmit -p packages/step-templates/tsconfig.json`
61-
62-## result
63-
64-- 补齐了 `planner` 抽象:策略、provider kind、step status、planner 输入上下文、`ProposedPlan` / `ProposedStep`、risk flags 建议值与 plan 校验 helper。
65-- 把常见任务模板整理成稳定数据结构,覆盖 `feature_impl`、`bugfix`、`review_only`、`ops_change`、`infra_bootstrap`。
66-- 提供了模板查询与 `buildTemplatePlan` helper,后续 conductor 可以直接据此产出结构化 plan。
67-
68-## risks
69-
70-- `review_only`、`ops_change`、`infra_bootstrap` 的 step kind 与输入 payload 需要靠设计文本推断,后续若设计补充更细契约,可能要同步收窄。
71-- conductor 侧还未接入这些类型与模板;真正落计划验收时,需要决定不同 task 何时走 `template_first`、何时走 `planner_assisted`。
72-
73-## next_handoff
74-
75-- conductor 可直接消费 `packages/planner` 的类型与 `validateProposedPlan`,并从 `packages/step-templates` 读取模板或调用 `buildTemplatePlan`。
76-
77-## notes
78-
79-- `2026-03-21`: 创建任务卡
80-- `2026-03-21`: 完成 planner 抽象与模板实现,等待 review
D coordination/tasks/done/T-008-ops-nginx.md
+0, -91
 1@@ -1,91 +0,0 @@
 2----
 3-task_id: T-008
 4-title: Nginx 与 VPS 运维
 5-status: done
 6-branch: feat/T-008-ops-nginx
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - ops/nginx/**
13-  - docs/ops/**
14-updated_at: 2026-03-21
15----
16-
17-# T-008 Nginx 与 VPS 运维
18-
19-## 目标
20-
21-把设计里的二级域名、Nginx 转发、直连节点域名、Basic Auth 与运维说明落到可执行配置和文档中。
22-
23-## 本任务包含
24-
25-- 完成 `ops/nginx/baa-conductor.conf`
26-- 补充 `includes/` 公共片段
27-- 编写 VPS 部署说明
28-- 明确 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 的配置关系
29-
30-## 本任务不包含
31-
32-- Cloudflare Worker 代码
33-- launchd
34-
35-## 建议起始文件
36-
37-- `ops/nginx/baa-conductor.conf`
38-- `ops/nginx/includes/common-proxy.conf`
39-- `ops/nginx/includes/direct-node-auth.conf`
40-- `docs/ops/README.md`
41-- `DESIGN.md` 第 6、7 节
42-
43-## 交付物
44-
45-- 可执行或接近可执行的 Nginx 配置
46-- VPS 运维说明
47-
48-## 验收
49-
50-- 包含统一入口
51-- 包含直达 mini/mac
52-- 包含 80 -> 443
53-- 包含鉴权或保护建议
54-
55-## files_changed
56-
57-- `coordination/tasks/T-008-ops-nginx.md`
58-- `docs/ops/README.md`
59-- `ops/nginx/baa-conductor.conf`
60-- `ops/nginx/includes/common-proxy.conf`
61-- `ops/nginx/includes/direct-node-auth.conf`
62-
63-## commands_run
64-
65-- `git worktree add ../baa-conductor-T008 -b feat/T-008-ops-nginx main`
66-- `command -v nginx`
67-- `git diff --check -- ops/nginx docs/ops coordination/tasks/T-008-ops-nginx.md`
68-- `rg -n "upstream conductor_primary|upstream mini_conductor_direct|upstream mac_conductor_direct|return 301 https://\\$host\\$request_uri|server_name conductor\\.makefile\\.so|server_name mini-conductor\\.makefile\\.so|server_name mac-conductor\\.makefile\\.so|ssl_certificate|auth_basic" ops/nginx/baa-conductor.conf ops/nginx/includes/direct-node-auth.conf`
69-- `rg -n "conductor\\.makefile\\.so|mini-conductor\\.makefile\\.so|mac-conductor\\.makefile\\.so|nginx -t|htpasswd|80/tcp|443/tcp|4317" docs/ops/README.md`
70-
71-## result
72-
73-- `ops/nginx/baa-conductor.conf` 已补齐 3 个 upstream、统一 `80 -> 443` 跳转、3 个 TLS server block,以及统一入口与直连 mini/mac 的转发关系
74-- `ops/nginx/includes/common-proxy.conf` 已补齐常用代理头、超时、request id、buffer 和 retry 配置
75-- `ops/nginx/includes/direct-node-auth.conf` 已默认启用 Basic Auth,并补充可选 IP allowlist 的使用说明
76-- `docs/ops/README.md` 已写明 VPS 部署、目录映射、证书准备、启用步骤、上线验证与保护建议
77-
78-## risks
79-
80-- 本机未安装 `nginx`,本次只完成静态合理性检查;上线前仍需在 VPS 上以真实证书和真实路径执行 `sudo nginx -t`
81-- `100.71.210.78`、`100.112.239.13` 与 `4317` 端口来自当前设计文档,真实部署前应再次确认 Tailscale 地址和端口未变化
82-- 若三域名经过 Cloudflare 代理,直连域名的 IP allowlist 需要先配置真实客户端 IP 恢复,否则 `allow/deny` 会基于 Cloudflare 出口 IP 生效
83-
84-## next_handoff
85-
86-- 把仓库中的 `ops/nginx/**` 同步到 VPS 的 `/etc/nginx/sites-available/` 与 `/etc/nginx/includes/baa-conductor/`
87-- 准备 `.htpasswd` 与 TLS 证书后,在 VPS 上执行 `sudo nginx -t && sudo systemctl reload nginx`
88-- 用 `curl` 验证统一入口的 `301/200`,以及 `mini-conductor.makefile.so`、`mac-conductor.makefile.so` 的 `401/200`
89-
90-## notes
91-
92-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-009-firefox-pause.md
+0, -81
 1@@ -1,81 +0,0 @@
 2----
 3-task_id: T-009
 4-title: Firefox 插件 Pause 与 Resume 协议
 5-status: done
 6-branch: feat/T-009-firefox-pause
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@56e9a4f
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - docs/firefox/**
13-updated_at: 2026-03-21
14----
15-
16-# T-009 Firefox 插件 Pause 与 Resume 协议
17-
18-## 目标
19-
20-先在本仓库中明确 Firefox 插件与 conductor control API 之间的协议,方便后续在插件仓库同步实现。
21-
22-## 统一开工要求
23-
24-- 必须从 `main@56e9a4f` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 定义浏览器按钮行为
31-- 定义状态读取字段
32-- 定义 `pause`、`resume`、`drain` 的请求格式
33-- 定义可见 `control` 与隐藏 `dispatch` 的职责边界
34-
35-## 本任务不包含
36-
37-- 直接修改 Firefox 插件仓库代码
38-- 完整 UI 实现
39-
40-## 建议起始文件
41-
42-- `DESIGN.md` 第 22、23 节
43-- `docs/firefox/README.md`
44-
45-## 交付物
46-
47-- 一份清晰的浏览器协议说明文档
48-
49-## 验收
50-
51-- 能明确告诉插件仓库该怎么接 control API
52-- 能明确区分 control 与 dispatch
53-
54-## files_changed
55-
56-- `coordination/tasks/T-009-firefox-pause.md`
57-- `docs/firefox/README.md`
58-
59-## commands_run
60-
61-- `git worktree add /Users/george/code/baa-conductor-T009 -b feat/T-009-firefox-pause main`
62-- `npx --yes pnpm install`
63-- `rg -n "Firefox|pause|resume|dispatch|control" DESIGN.md docs/firefox/README.md`
64-- `git diff --stat`
65-
66-## result
67-
68-- 已在 `docs/firefox/README.md` 定义 Firefox 插件与 control API 的协议,包括状态读取字段、`pause/resume/drain` 请求体、按钮启用规则、成功/失败响应,以及 `control` 与 `dispatch` 的职责边界。
69-- 已把任务卡基线修正为本次实际要求的 `main@56e9a4f`。
70-
71-## risks
72-
73-- `docs/firefox/README.md` 当前定义的是协议契约,仍需要后续 `T-003` / `baa-firefox` 实现方按该契约落地接口与 UI。
74-- `coordination/SECOND_WAVE_START.md` 在当前仓库树中不存在;本任务实际依据 `DESIGN.md`、`TASK_OVERVIEW.md`、`WORKFLOW.md` 与任务卡执行。
75-
76-## next_handoff
77-
78-- `baa-firefox` 仓库按 `docs/firefox/README.md` 接入 control API,并与 `T-003` 对齐返回字段和错误码。
79-
80-## notes
81-
82-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-010-status-api.md
+0, -87
 1@@ -1,87 +0,0 @@
 2----
 3-task_id: T-010
 4-title: Status API 与基础 UI
 5-status: done
 6-branch: feat/T-010-status-api
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@56e9a4f
 9-depends_on:
10-  - T-003
11-  - T-004
12-write_scope:
13-  - apps/status-api/**
14-updated_at: 2026-03-21
15----
16-
17-# T-010 Status API 与基础 UI
18-
19-## 目标
20-
21-实现最小状态读取层,让人和浏览器能读取 leader、mode、queue depth、active runs 等关键信息。
22-
23-## 统一开工要求
24-
25-- 必须从 `main@56e9a4f` 切出该分支
26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
27-- 不允许从其他任务分支切分支
28-
29-## 本任务包含
30-
31-- status snapshot 结构完善
32-- 状态读取接口骨架
33-- 基础 UI 或最小渲染接口预留
34-
35-## 本任务不包含
36-
37-- 完整管理后台
38-- 真实图表
39-
40-## 建议起始文件
41-
42-- `apps/status-api/src/index.ts`
43-- `DESIGN.md` 第 23、29 节
44-
45-## 交付物
46-
47-- status API 骨架
48-- 最小状态结构
49-
50-## 验收
51-
52-- 至少能表达 mode、leader、queue depth、active runs
53-
54-## files_changed
55-
56-- `apps/status-api/src/contracts.ts`
57-- `apps/status-api/src/data-source.ts`
58-- `apps/status-api/src/render.ts`
59-- `apps/status-api/src/service.ts`
60-- `apps/status-api/src/index.ts`
61-- `apps/status-api/tsconfig.json`
62-- `coordination/tasks/T-010-status-api.md`
63-
64-## commands_run
65-
66-- `npx --yes pnpm install`
67-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
68-- `npx --yes tsx -e '...'`
69-
70-## result
71-
72-- 已实现最小 status snapshot 契约,包含 `mode`、`leader`、`queueDepth`、`activeRuns`、lease 元数据和观测时间。
73-- 已实现基于 D1 的只读快照加载器,读取 automation state、leader lease、queued task 数、active run 数。
74-- 已实现最小状态 API/渲染骨架,支持 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 与 `/` HTML 面板。
75-
76-## risks
77-
78-- 目前只交付包内 handler 与渲染层,尚未绑定真实 HTTP server;后续需要由整合者或依赖任务接入运行时。
79-- `queueDepth` 当前按 `tasks.status = 'queued'` 统计,`activeRuns` 按 `task_runs.started_at IS NOT NULL AND finished_at IS NULL` 统计;如果后续运行态枚举收敛,需要一起校准。
80-
81-## next_handoff
82-
83-- 将 `D1StatusSnapshotLoader` 接到真实 `D1DatabaseLike`,再把 `createStatusApiHandler()` 挂到 status-api 运行时或 control/browse 面板入口。
84-
85-## notes
86-
87-- `2026-03-21`: 创建任务卡
88-- `2026-03-21`: 从 `main@56e9a4f` 建立独立 worktree,完成状态 API 与最小 HTML 面板骨架并进入 review。
D coordination/tasks/done/T-011-launchd-runtime.md
+0, -84
 1@@ -1,84 +0,0 @@
 2----
 3-task_id: T-011
 4-title: launchd 与本地 Runtime 布局
 5-status: done
 6-branch: feat/T-011-launchd-runtime
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - ops/launchd/**
13-  - docs/runtime/**
14-updated_at: 2026-03-21T19:41:24+0800
15----
16-
17-# T-011 launchd 与本地 Runtime 布局
18-
19-## 目标
20-
21-把 mini 与 mac 上的 launchd 配置、运行目录创建方式、环境变量与安装步骤整理清楚。
22-
23-## 本任务包含
24-
25-- 完善 `ops/launchd/*.plist`
26-- 说明 `~/Library/LaunchAgents` 与 `/Library/LaunchDaemons` 的差异
27-- 说明 `runs/`、`worktrees/`、`logs/`、`tmp/` 的初始化方式
28-
29-## 本任务不包含
30-
31-- 真实业务逻辑
32-- D1 schema
33-
34-## 建议起始文件
35-
36-- `ops/launchd/*.plist`
37-- `docs/runtime/README.md`
38-- `DESIGN.md` 第 25、27 节
39-
40-## 交付物
41-
42-- launchd 配置模板
43-- 本地 runtime 初始化说明
44-
45-## 验收
46-
47-- 能明确 mini 与 mac 分别怎么安装
48-- 能明确需要哪些环境变量
49-
50-## files_changed
51-
52-- `coordination/tasks/T-011-launchd-runtime.md`
53-- `docs/runtime/README.md`
54-- `docs/runtime/layout.md`
55-- `docs/runtime/environment.md`
56-- `docs/runtime/launchd.md`
57-- `ops/launchd/so.makefile.baa-conductor.plist`
58-- `ops/launchd/so.makefile.baa-worker-runner.plist`
59-- `ops/launchd/so.makefile.baa-status-api.plist`
60-
61-## commands_run
62-
63-- `git worktree add /Users/george/code/baa-conductor-T011 -b feat/T-011-launchd-runtime main`
64-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist`
65-- `plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist`
66-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
67-- `git diff --check`
68-
69-## result
70-
71-- 已补全 `launchd` plist 模板,加入 runtime 目录、日志路径、环境变量和 mini 默认值
72-- 已新增 runtime 文档,明确 `mini` / `mac` 的安装方式、`LaunchAgents` / `LaunchDaemons` 差异,以及 `runs/`、`worktrees/`、`logs/`、`tmp/` 的初始化规则
73-
74-## risks
75-
76-- 当前仓库的 app 构建脚本仍不产出真实 `dist/index.js`,因此本任务只能静态校验 plist 和文档,不能验证真实 `launchctl bootstrap` 启动成功
77-- `BAA_SHARED_TOKEN` 仍需在安装副本中手工替换,真实部署时如果漏改会直接导致鉴权失败
78-
79-## next_handoff
80-
81-- 后续接入真实服务产物后,按 `docs/runtime/launchd.md` 在 `mini` 与 `mac` 上分别复制、改写并加载 plist,再做一次端到端启动验证
82-
83-## notes
84-
85-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-012-auth-model.md
+0, -89
 1@@ -1,89 +0,0 @@
 2----
 3-task_id: T-012
 4-title: 鉴权与 Token 模型
 5-status: done
 6-branch: feat/T-012-auth-model
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main
 9-depends_on:
10-  - T-001
11-write_scope:
12-  - packages/auth/**
13-  - docs/auth/**
14-updated_at: 2026-03-21T19:41:56+0800
15----
16-
17-# T-012 鉴权与 Token 模型
18-
19-## 目标
20-
21-把设计中的角色、token、权限边界落实成清晰的鉴权模型和代码挂载点。
22-
23-## 本任务包含
24-
25-- 角色定义
26-- token 形式说明
27-- middleware 或校验器骨架
28-- `controller`、`worker`、`browser_admin`、`ops_admin`、`readonly` 的权限矩阵
29-
30-## 本任务不包含
31-
32-- 完整生产级 secrets 管理
33-- 外部身份供应商接入
34-
35-## 建议起始文件
36-
37-- `packages/auth/src/index.ts`
38-- `docs/auth/README.md`
39-- `DESIGN.md` 第 11.3、11.4、28 节
40-
41-## 交付物
42-
43-- 鉴权设计文档
44-- 代码侧鉴权挂载点
45-
46-## 验收
47-
48-- 角色边界清晰
49-- 写接口与读接口权限可区分
50-- 浏览器控制与 controller 调度权限可区分
51-
52-## files_changed
53-
54-- `coordination/tasks/T-012-auth-model.md`
55-- `docs/auth/README.md`
56-- `packages/auth/package.json`
57-- `packages/auth/src/index.ts`
58-- `packages/auth/src/actions.ts`
59-- `packages/auth/src/model.ts`
60-- `packages/auth/src/policy.ts`
61-- `packages/auth/src/control-api.ts`
62-
63-## commands_run
64-
65-- `git switch -c feat/T-012-auth-model main`
66-- `pnpm --filter @baa-conductor/auth typecheck` (`pnpm` 不在当前环境 PATH 中,未执行成功)
67-- `./node_modules/.bin/tsc --noEmit -p /Users/george/code/baa-conductor/packages/auth/tsconfig.json`
68-
69-## result
70-
71-- 明确了 `controller`、`worker`、`browser_admin`、`ops_admin`、`readonly` 的角色边界与允许动作
72-- 定义了 `service_hmac`、`service_signed`、`browser_session`、`ops_session` 四类 token 模型与 principal 形状
73-- 在 `packages/auth` 下建立了 action、token/principal、授权策略、Control API route 授权映射四层骨架
74-- 在 `docs/auth/README.md` 中整理了权限矩阵、资源归属规则和 `T-003` 接入方式
75-
76-## risks
77-
78-- 还没有真实 token 签发、签名校验与密钥轮换实现
79-- 资源归属目前只约束到 `controllerId` / `workerId`,后续可能需要细化到 `run_id` / `step_id`
80-- `control-api-worker` 还未接入本包,`T-003` 仍需补 verifier 注入和 DB 归属校验
81-
82-## next_handoff
83-
84-- `T-003` 先把 `@baa-conductor/auth` 作为 workspace 依赖接入,并复用 `CONTROL_API_AUTH_RULES` 与 `authorizeControlApiRoute(...)`
85-- step 写接口在鉴权通过后继续结合数据库中的 `assigned_worker_id` 做最终归属校验
86-- controller 写接口在鉴权通过后继续结合 leader term、owner 字段做冲突与幂等检查
87-
88-## notes
89-
90-- `2026-03-21`: 创建任务卡
D coordination/tasks/done/T-013-build-runtime.md
+0, -113
  1@@ -1,113 +0,0 @@
  2----
  3-task_id: T-013
  4-title: Build 与 dist 产物
  5-status: done
  6-branch: feat/T-013-build-runtime
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@c5e007b
  9-depends_on:
 10-  - T-011
 11-write_scope:
 12-  - package.json
 13-  - tsconfig.base.json
 14-  - apps/conductor-daemon/package.json
 15-  - apps/conductor-daemon/tsconfig.json
 16-  - apps/control-api-worker/package.json
 17-  - apps/control-api-worker/tsconfig.json
 18-  - apps/status-api/package.json
 19-  - apps/status-api/tsconfig.json
 20-  - apps/worker-runner/package.json
 21-  - apps/worker-runner/tsconfig.json
 22-  - docs/runtime/**
 23-updated_at: 2026-03-22
 24----
 25-
 26-# T-013 Build 与 dist 产物
 27-
 28-## 目标
 29-
 30-把当前仓库从“只有 typecheck,没有真实产物”推进到“至少 apps 能产出 `dist/index.js`”,让 launchd 模板不再只是静态占位。
 31-
 32-## 统一开工要求
 33-
 34-- 必须从当前最新 `origin/main` 切出该分支
 35-- 新 worktree 进入后先执行 `npx --yes pnpm install`
 36-- 不允许从其他任务分支切分支
 37-
 38-## 本任务包含
 39-
 40-- 修正根 `build` 脚本
 41-- 让 `apps/*` 的 `build` 真正输出 JS
 42-- 调整必要的 tsconfig,使 `dist/` 可生成
 43-- 如果需要,补最小的 build 使用说明到 `docs/runtime/**`
 44-
 45-## 本任务不包含
 46-
 47-- 真正运行 launchd
 48-- 真实业务逻辑接线
 49-- 包级发布配置
 50-
 51-## 建议起始文件
 52-
 53-- `package.json`
 54-- `tsconfig.base.json`
 55-- `apps/*/package.json`
 56-- `apps/*/tsconfig.json`
 57-- `docs/runtime/launchd.md`
 58-
 59-## 交付物
 60-
 61-- `pnpm -r build` 可用
 62-- apps 产出 `dist/index.js`
 63-
 64-## 验收
 65-
 66-- `npx --yes pnpm -r build` 通过
 67-- `apps/conductor-daemon/dist/index.js` 存在
 68-- `apps/worker-runner/dist/index.js` 存在
 69-- `apps/status-api/dist/index.js` 存在
 70-
 71-## files_changed
 72-
 73-- `coordination/tasks/T-013-build-runtime.md`
 74-- `package.json`
 75-- `apps/conductor-daemon/package.json`
 76-- `apps/control-api-worker/package.json`
 77-- `apps/status-api/package.json`
 78-- `apps/worker-runner/package.json`
 79-- `docs/runtime/README.md`
 80-- `docs/runtime/launchd.md`
 81-
 82-## commands_run
 83-
 84-- `git worktree add /Users/george/code/baa-conductor-T013 -b feat/T-013-build-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
 85-- `npx --yes pnpm install`
 86-- `npx --yes pnpm -r build`
 87-- `node --input-type=module -e "await import('./apps/conductor-daemon/dist/index.js'); console.log('ok conductor-daemon');"`
 88-- `node --input-type=module -e "await import('./apps/control-api-worker/dist/index.js'); console.log('ok control-api-worker');"`
 89-- `node --input-type=module -e "await import('./apps/status-api/dist/index.js'); console.log('ok status-api');"`
 90-- `node --input-type=module -e "await import('./apps/worker-runner/dist/index.js'); console.log('ok worker-runner');"`
 91-
 92-## result
 93-
 94-- 四个 app 的 `build` 已从 `tsc --noEmit` 改成真实 emit,其中 `conductor-daemon` 直接输出 `dist/index.js`
 95-- 为 `control-api-worker`、`status-api`、`worker-runner` 增加了统一 postbuild 处理:固定根入口到 `dist/index.js`,并在需要时改写编译后 import specifier
 96-- `control-api-worker` 与 `worker-runner` 的编译产物已改写为引用同一 `dist/` 下同步生成的 package JS 文件,`worker-runner` 产物里的相对 import 也已补齐 `.js` 扩展
 97-- `npx --yes pnpm -r build` 通过,四个 `apps/*/dist/index.js` 均存在,且可被 `node` 直接 `import(...)`
 98-- `docs/runtime` 已更新为“先 build 再 launchd bootstrap”的当前约定,不再声明仓库缺少 `dist/index.js`
 99-
100-## risks
101-
102-- 目前 package 自身仍然只做 typecheck,没有形成独立发布级 `dist/`;`control-api-worker` 与 `worker-runner` 依赖 app build 阶段的产物改写
103-- 根 `package.json` 中的 postbuild helper 目前以内联脚本维护,后续如果构建规则继续变复杂,最好拆成独立脚本文件
104-- 本任务解决的是“产物路径稳定且可加载”,不是“服务运行时逻辑全部接好”;`T-014`、`T-015`、`T-017` 仍需补真正启动流程
105-
106-## next_handoff
107-
108-- `T-014` 可以直接把 `apps/control-api-worker/dist/index.js` 当作稳定入口做运行时接线,不必再解决 bare workspace import 问题
109-- `T-015` 可以基于 `apps/conductor-daemon/dist/index.js` 继续补 CLI/daemon 启动逻辑,并复用文档里“先 build 再 bootstrap”的流程
110-- `T-017` 可以直接围绕 `apps/status-api/dist/index.js` 挂 HTTP 启动入口;若后续需要 package 级独立 dist,可另拆构建任务
111-
112-## notes
113-
114-- `2026-03-21`: 创建第三波任务卡
D coordination/tasks/done/T-014-control-api-runtime.md
+0, -99
  1@@ -1,99 +0,0 @@
  2----
  3-task_id: T-014
  4-title: Control API 运行时接线
  5-status: done
  6-branch: feat/T-014-control-api-runtime
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@c5e007b
  9-depends_on:
 10-  - T-003
 11-  - T-012
 12-write_scope:
 13-  - apps/control-api-worker/**
 14-updated_at: 2026-03-22
 15----
 16-
 17-# T-014 Control API 运行时接线
 18-
 19-## 目标
 20-
 21-把第二波已经做好的 control-api 骨架推进到“可真正承载运行时接线”的程度,包括 env、auth 注入、repository 注入和基础 handler 落点。
 22-
 23-## 统一开工要求
 24-
 25-- 必须从当前最新 `origin/main` 切出该分支
 26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
 27-- 不允许从其他任务分支切分支
 28-
 29-## 本任务包含
 30-
 31-- 明确 Worker `Env` 结构
 32-- 把 auth hook 真正接入 route 执行流
 33-- 整理 repository 注入点
 34-- 尽量实现可安全落地的 read-only handler 或 mutation glue 代码
 35-
 36-## 本任务不包含
 37-
 38-- 修改 `packages/auth/**`
 39-- 修改 `packages/db/**`
 40-- 修改 conductor 代码
 41-
 42-## 建议起始文件
 43-
 44-- `apps/control-api-worker/src/index.ts`
 45-- `apps/control-api-worker/src/router.ts`
 46-- `apps/control-api-worker/src/handlers.ts`
 47-- `apps/control-api-worker/src/contracts.ts`
 48-
 49-## 交付物
 50-
 51-- 更像真实 Worker 的运行时入口
 52-- auth 与 repository 挂载方式
 53-- 至少部分 handler 不再只是纯占位
 54-
 55-## 验收
 56-
 57-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 58-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 59-- 路由、auth、env、repository 关系清晰
 60-
 61-## files_changed
 62-
 63-- `apps/control-api-worker/src/contracts.ts`
 64-- `apps/control-api-worker/src/handlers.ts`
 65-- `apps/control-api-worker/src/index.ts`
 66-- `apps/control-api-worker/src/router.ts`
 67-- `apps/control-api-worker/src/runtime.ts`
 68-- `apps/control-api-worker/src/schemas.ts`
 69-- `coordination/tasks/T-014-control-api-runtime.md`
 70-
 71-## commands_run
 72-
 73-- `git worktree add /Users/george/code/baa-conductor-T014 -b feat/T-014-control-api-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
 74-- `npx --yes pnpm install`
 75-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 76-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 77-
 78-## result
 79-
 80-- 明确了 `ControlApiEnv` 与运行时服务结构,新增 env 驱动的 auth/repository 装配层,并支持显式注入 `authHook`、`tokenVerifier`、`repository` 与 `now`。
 81-- 把 auth hook 真正接入请求执行流;当配置了 runtime token 或外部 verifier 时,路由会执行 Bearer 提取、principal 解析和 `authorizeControlApiRoute(...)` 授权。
 82-- 落地了可安全运行的 handler:`controllers.heartbeat`、`leader.acquire`、`tasks.create`、`system.pause` / `resume` / `drain`、`system.state`、`tasks.read` 已接到真实 repository 调用;其余未完成路由仍保持显式 `501`。
 83-- 为已接线路由补了最小 request 校验和 `repository_not_configured` / `invalid_request` / `*_not_found` 失败路径,使运行时错误更明确。
 84-
 85-## risks
 86-
 87-- `BAA_SHARED_TOKEN` 模式仍是最小实现:controller / worker 的身份会从当前请求资源推断,尚未做到签名 claim 或强身份绑定。
 88-- `tasks.plan`、`tasks.claim`、step 回写、task logs、run detail 仍是占位实现;完整 durable 调度链路仍依赖后续任务继续接入。
 89-- 如果没有配置 runtime token / verifier 且未显式打开 `CONTROL_API_AUTH_REQUIRED`,auth 仍会退化为跳过模式;生产部署时应显式配置鉴权输入。
 90-
 91-## next_handoff
 92-
 93-- `T-015` / conductor 运行时可以直接复用新的 `leader.acquire`、`controllers.heartbeat` 路由和 env auth wiring。
 94-- 后续任务可沿当前 `runtime.ts` 的装配点继续接 `tasks.plan`、`tasks.claim`、`steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` 的真实持久化逻辑。
 95-- `T-017` 可直接消费 `GET /v1/system/state` 与 `GET /v1/tasks/:task_id`;如需日志与 run 详情,需要补本地 query/repository 读取路径。
 96-
 97-## notes
 98-
 99-- `2026-03-21`: 创建第三波任务卡
100-- `2026-03-22`: 完成 control-api 运行时接线并通过目标包 `typecheck` / `build`
D coordination/tasks/done/T-015-conductor-runtime.md
+0, -92
 1@@ -1,92 +0,0 @@
 2----
 3-task_id: T-015
 4-title: Conductor 运行时接线
 5-status: done
 6-branch: feat/T-015-conductor-runtime
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@c5e007b
 9-depends_on:
10-  - T-004
11-write_scope:
12-  - apps/conductor-daemon/**
13-updated_at: 2026-03-22
14----
15-
16-# T-015 Conductor 运行时接线
17-
18-## 目标
19-
20-把 conductor 从“状态机骨架”推进到“有可启动入口、有 env/CLI 配置、有 control-api client 接线”的程度。
21-
22-## 统一开工要求
23-
24-- 必须从当前最新 `origin/main` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- env / CLI 配置解析
31-- 构造可启动的 daemon 入口
32-- 组织 heartbeat / lease loop 启动流程
33-- 暴露最小状态快照或运行时接口
34-
35-## 本任务不包含
36-
37-- 修改 `packages/db/**`
38-- 修改 control-api-worker
39-- 完整调度器
40-
41-## 建议起始文件
42-
43-- `apps/conductor-daemon/src/index.ts`
44-- `apps/conductor-daemon/src/index.test.js`
45-
46-## 交付物
47-
48-- 可以启动的 conductor 运行时入口
49-- 更明确的主备启动流程
50-
51-## 验收
52-
53-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
54-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
55-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
56-
57-## files_changed
58-
59-- `apps/conductor-daemon/package.json`
60-- `apps/conductor-daemon/src/index.ts`
61-- `apps/conductor-daemon/src/index.test.js`
62-
63-## commands_run
64-
65-- `npx --yes pnpm install`
66-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
67-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
68-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
69-- `node apps/conductor-daemon/dist/index.js start --node-id mini-main --host mini --role primary --control-api-base http://127.0.0.1:9 --run-once --json`
70-- `git diff --check`
71-
72-## result
73-
74-- `apps/conductor-daemon` 现在有可执行的 CLI/runtime 入口,支持 `start`、`config`、`checklist`,并能从 env/CLI 解析节点身份、control-api 地址、共享 token 与本地目录配置。
75-- control-api client 已按第三波 contract 补上 Bearer 认证、统一 envelope 解包,以及 `snake_case` 请求/响应兼容,能对接 `/v1/controllers/heartbeat` 和 `/v1/leader/acquire`。
76-- runtime 侧新增最小快照接口 `ConductorRuntime.getRuntimeSnapshot()`,并把启动流程整理为:初始 heartbeat、初始 lease cycle、本地 runs hook、后台 loop 启动;同时补了 CLI/config/client/runtime 相关测试。
77-- `conductor-daemon` 自身 `build` 脚本现在会产出 `dist/index.js`,验证过构建后的入口可以直接启动并在 control-api 不可达时稳定落到 `degraded` 快照。
78-
79-## risks
80-
81-- `apps/control-api-worker` 当前主线仍以占位 handler 为主;虽然 client 侧协议已对齐,但真实 heartbeat / lease 持久化效果仍依赖 `T-014` 把 handler 接到 repository。
82-- 最小 runtime 接口当前是进程内快照与 CLI 输出,还没有单独的 loopback HTTP 服务;如果后续需要让 status-api 或 launchd 直接探活,可能还要在 `apps/conductor-daemon/**` 内补本地只读端点。
83-- `conductor-daemon` 的 app 级 build 现已可产出 dist,但根级统一构建链路仍需 `T-013` 做最终收口,避免后续在脚本层重复调整。
84-
85-## next_handoff
86-
87-- `T-014` 可以直接用当前 client 期望的 Bearer + envelope + `snake_case` 约定实现真实 handler,并把 `/v1/controllers/heartbeat`、`/v1/leader/acquire` 返回值收敛到已测试的兼容形状。
88-- 后续 scheduler / status 相关任务可以直接复用 `ConductorRuntime` 与 `getRuntimeSnapshot()` 作为启动态、主备态和配置态的最小读取接口。
89-- `T-013` 如需统一根级 build/launchd 行为,应保留 `apps/conductor-daemon/dist/index.js` 作为稳定入口路径。
90-
91-## notes
92-
93-- `2026-03-21`: 创建第三波任务卡
D coordination/tasks/done/T-016-worker-persistence.md
+0, -98
 1@@ -1,98 +0,0 @@
 2----
 3-task_id: T-016
 4-title: Worker 本地持久化
 5-status: done
 6-branch: feat/T-016-worker-persistence
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@c5e007b
 9-depends_on:
10-  - T-005
11-  - T-006
12-write_scope:
13-  - apps/worker-runner/**
14-  - packages/checkpointing/**
15-  - packages/logging/**
16-updated_at: 2026-03-22
17----
18-
19-# T-016 Worker 本地持久化
20-
21-## 目标
22-
23-把 worker-runner 从“内存态结构”推进到“会把 meta/state/log/checkpoint 真正写入本地目录”的程度。
24-
25-## 统一开工要求
26-
27-- 必须从当前最新 `origin/main` 切出该分支
28-- 新 worktree 进入后先执行 `npx --yes pnpm install`
29-- 不允许从其他任务分支切分支
30-
31-## 本任务包含
32-
33-- 本地目录创建
34-- `meta.json`、`state.json` 写入
35-- `worker.log`、`stdout.log`、`stderr.log` 落盘
36-- checkpoint 文件真正写入 `checkpoints/`
37-
38-## 本任务不包含
39-
40-- 真实 Codex 子进程接入
41-- D1 checkpoint 上报
42-- failover 恢复全链路
43-
44-## 建议起始文件
45-
46-- `apps/worker-runner/src/runner.ts`
47-- `apps/worker-runner/src/checkpoints.ts`
48-- `packages/checkpointing/src/index.ts`
49-- `packages/logging/src/*`
50-
51-## 交付物
52-
53-- 本地持久化 runner 骨架
54-- checkpoint 文件真正落盘
55-
56-## 验收
57-
58-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
59-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
60-- 能明确说明哪些文件会落到本地 runtime 目录
61-
62-## files_changed
63-
64-- `apps/worker-runner/src/runner.ts`
65-- `packages/checkpointing/src/index.ts`
66-- `packages/checkpointing/src/node-shims.ts`
67-- `packages/logging/src/index.ts`
68-- `packages/logging/src/node-shims.ts`
69-- `packages/logging/src/persistence.ts`
70-- `coordination/tasks/T-016-worker-persistence.md`
71-
72-## commands_run
73-
74-- `npx --yes pnpm install`
75-- `npx --yes pnpm --filter @baa-conductor/logging typecheck`
76-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
77-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
78-- `npx --yes pnpm exec tsc -p apps/worker-runner/tsconfig.json --outDir <tmp> --module commonjs`
79-- `DIST_ROOT=<tmp> RUNTIME_ROOT=<tmp> node <<'EOF' ... EOF`
80-
81-## result
82-
83-- `worker-runner` 现在会创建本地 run 目录,并在初始化时写入 `meta.json`、`state.json`、空的 `worker.log` / `stdout.log` / `stderr.log`。
84-- 生命周期事件、stdout/stderr chunk、checkpoint 写入后都会同步刷新本地 `state.json`,不再只停留在内存态。
85-- `summary` 与 `log_tail` checkpoint 会真实落到 `checkpoints/`,并已通过临时 CommonJS 编译验证生成 `0001-summary.json`、`0002-log-tail.txt`。
86-
87-## risks
88-
89-- 真实 Codex 子进程尚未接入,当前只验证了 placeholder/custom executor 路径下的本地持久化。
90-- `git_diff`、`test_output` 等更大 checkpoint 负载还没有实际生成端,当前仅补齐了文件写入基础设施。
91-
92-## next_handoff
93-
94-- 将真实 worker 执行器接到 `appendStreamChunkPersisted` / `recordLifecycleEventPersisted`,保持 stdout/stderr 与状态文件持续落盘。
95-- 在后续 checkpoint 任务里直接复用 `persistCheckpointRecord` 扩展 `git_diff`、`test_output` 的实际产出。
96-
97-## notes
98-
99-- `2026-03-21`: 创建第三波任务卡
D coordination/tasks/done/T-017-status-runtime.md
+0, -93
 1@@ -1,93 +0,0 @@
 2----
 3-task_id: T-017
 4-title: Status API 运行时入口
 5-status: done
 6-branch: feat/T-017-status-runtime
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@c5e007b
 9-depends_on:
10-  - T-010
11-write_scope:
12-  - apps/status-api/**
13-updated_at: 2026-03-22
14----
15-
16-# T-017 Status API 运行时入口
17-
18-## 目标
19-
20-把 status-api 从“包内 handler 骨架”推进到“有可挂载运行时入口”的程度。
21-
22-## 统一开工要求
23-
24-- 必须从当前最新 `origin/main` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 明确 status-api 对外入口
31-- 整理 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`
32-- 提供最小 fetch handler 或 node server adapter
33-
34-## 本任务不包含
35-
36-- 修改 control-api-worker
37-- 修改 conductor
38-- 真实部署配置
39-
40-## 建议起始文件
41-
42-- `apps/status-api/src/index.ts`
43-- `apps/status-api/src/service.ts`
44-- `apps/status-api/src/render.ts`
45-
46-## 交付物
47-
48-- 可挂载的 status-api 入口
49-- 更完整的 HTML/JSON 响应骨架
50-
51-## 验收
52-
53-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
54-- `npx --yes pnpm --filter @baa-conductor/status-api build`
55-- 入口结构清晰,后续可直接接入 launchd 或本地 HTTP server
56-
57-## files_changed
58-
59-- `apps/status-api/package.json`
60-- `apps/status-api/tsconfig.json`
61-- `apps/status-api/src/contracts.ts`
62-- `apps/status-api/src/index.ts`
63-- `apps/status-api/src/runtime.ts`
64-- `apps/status-api/src/service.ts`
65-- `coordination/tasks/T-017-status-runtime.md`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/status-api build`
72-- `node --input-type=module -e "import { createStatusApiRuntime } from './apps/status-api/dist/apps/status-api/src/index.js'; ..."`
73-
74-## result
75-
76-- 已将 status-api 从包内 handler 扩展为可挂载的 fetch 运行时入口,新增 `createStatusApiRuntime()` 与 `createStatusApiFetchHandler()`,可直接对接标准 `Request`/`Response`。
77-- 已整理对外路由面,明确以 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 为 canonical surface,并把 `/`、`/ui` 保留为 UI 别名。
78-- 已将 `build` 改为真实 `tsc` 发射,确认生成 `apps/status-api/dist/**` 产物,并用构建后的运行时代码冒烟验证三条 GET 路由。
79-
80-## risks
81-
82-- 默认运行时仍使用 `StaticStatusSnapshotLoader`;真正接入 D1 或本地控制平面数据库仍需由后续整合步骤注入 `D1StatusSnapshotLoader`。
83-- 当前 dist 入口路径受 `rootDir: ../..` 影响为 `dist/apps/status-api/src/*.js`;如果后续统一构建任务收敛到平铺的 `dist/index.js`,需要同步调整 `package.json` 的 `main`/`exports`。
84-- fetch 运行时假设宿主环境提供标准 Fetch API;若后续必须在更旧的 Node 版本运行,需要额外补 polyfill 或改为 node server adapter。
85-
86-## next_handoff
87-
88-- 在 status-api 的宿主进程中注入真实 `D1StatusSnapshotLoader`,把 `createStatusApiRuntime()` 挂到本地 HTTP server、launchd 进程或上层 router。
89-- 若仓库后续统一 dist 布局,顺手把 `@baa-conductor/status-api` 的导出路径更新到新的 build 产物位置。
90-
91-## notes
92-
93-- `2026-03-21`: 创建第三波任务卡
94-- `2026-03-22`: 从 `main@c5e007b` 建立独立 worktree,补齐 status-api fetch 运行时入口与 canonical route surface,完成验证并进入 review。
D coordination/tasks/done/T-018-control-api-deploy.md
+0, -111
  1@@ -1,111 +0,0 @@
  2----
  3-task_id: T-018
  4-title: Cloudflare Worker 与 D1 部署配置
  5-status: done
  6-branch: feat/T-018-control-api-deploy
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@458d7cf
  9-depends_on:
 10-  - T-014
 11-write_scope:
 12-  - apps/control-api-worker/**
 13-  - ops/cloudflare/**
 14-updated_at: 2026-03-22
 15----
 16-
 17-# T-018 Cloudflare Worker 与 D1 部署配置
 18-
 19-## 目标
 20-
 21-把 `control-api-worker` 从“可构建的 Worker 代码”推进到“具备明确 Cloudflare Worker / D1 绑定配置、可部署模板和最小运维说明”的程度。
 22-
 23-## 本任务包含
 24-
 25-- 为 `apps/control-api-worker` 补部署配置文件,例如 `wrangler.jsonc`
 26-- 明确 D1 binding、环境变量和 Worker 入口约定
 27-- 在 `ops/cloudflare/**` 下补部署示例、变量模板或辅助脚本
 28-- 把当前 runtime 代码与部署配置对齐,保证入口和 bindings 命名一致
 29-
 30-## 本任务不包含
 31-
 32-- 实际上线部署到 Cloudflare
 33-- 修改 `packages/db/**`
 34-- 修改 `packages/auth/**`
 35-- 修改 conductor 或 status-api 代码
 36-
 37-## 建议起始文件
 38-
 39-- `apps/control-api-worker/src/index.ts`
 40-- `apps/control-api-worker/src/contracts.ts`
 41-- `apps/control-api-worker/package.json`
 42-- `ops/cloudflare/`
 43-
 44-## 交付物
 45-
 46-- 可提交到仓库的 Worker / D1 配置骨架
 47-- 清晰的 binding 与 secret 约定
 48-- 最小部署说明或辅助脚本
 49-
 50-## 验收
 51-
 52-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 53-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 54-- Worker 配置文件与代码里的 env/binding 名称一致
 55-
 56-## 更新要求
 57-
 58-完成时更新 frontmatter 的:
 59-
 60-- `status`
 61-- `base_ref`
 62-- `updated_at`
 63-
 64-并补充下面这些内容:
 65-
 66-## files_changed
 67-
 68-- `apps/control-api-worker/.dev.vars.example`
 69-- `apps/control-api-worker/.gitignore`
 70-- `apps/control-api-worker/package.json`
 71-- `apps/control-api-worker/src/contracts.ts`
 72-- `apps/control-api-worker/src/handlers.ts`
 73-- `apps/control-api-worker/src/runtime.ts`
 74-- `apps/control-api-worker/wrangler.jsonc`
 75-- `ops/cloudflare/README.md`
 76-- `ops/cloudflare/apply-control-api-d1-migrations.sh`
 77-- `ops/cloudflare/control-api-worker.secrets.example.env`
 78-- `ops/cloudflare/deploy-control-api-worker.sh`
 79-- `coordination/tasks/T-018-control-api-deploy.md`
 80-
 81-## commands_run
 82-
 83-- `git worktree add /Users/george/code/baa-conductor-T018 -b feat/T-018-control-api-deploy 458d7cf`
 84-- `npx --yes pnpm install`
 85-- `bash -n ops/cloudflare/deploy-control-api-worker.sh ops/cloudflare/apply-control-api-d1-migrations.sh`
 86-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 87-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 88-
 89-## result
 90-
 91-- 为 `control-api-worker` 增加了可提交的 `wrangler.jsonc`,固定了 Worker 名称、入口 `dist/index.js`、自定义域 `control-api.makefile.so`、D1 binding `CONTROL_DB` 和最小运行时变量。
 92-- 在 `contracts.ts` / `runtime.ts` / `handlers.ts` 中把 D1 binding 与 token/env 名称抽成常量并接入运行时,减少代码和部署配置之间的漂移。
 93-- 在 `ops/cloudflare/` 下补了最小运维材料:部署说明、secret 模板、远端 D1 migration 脚本、构建后部署脚本;同时在包脚本中暴露了 Cloudflare 相关入口。
 94-
 95-## risks
 96-
 97-- `apps/control-api-worker/wrangler.jsonc` 中的 `database_id` 与 `preview_database_id` 仍是占位值;实际部署前必须替换为真实 D1 UUID。
 98-- 本任务只提供 secret 模板和 `wrangler secret put` 约定,真实 secret 仍需在目标 Cloudflare 账号中手动注入。
 99-- 未执行真实 Cloudflare deploy / D1 远端 migration,因此自定义域、账号权限和线上资源绑定仍需落地验证。
100-
101-## next_handoff
102-
103-- 先填写 `wrangler.jsonc` 的真实 D1 UUID,再执行 `./ops/cloudflare/apply-control-api-d1-migrations.sh`。
104-- 按 `ops/cloudflare/control-api-worker.secrets.example.env` 的键名把生产 secret 写入 Cloudflare Worker。
105-- 完成后执行 `./ops/cloudflare/deploy-control-api-worker.sh`,并验证 `https://control-api.makefile.so` 是否能正常返回 Worker 响应。
106-
107-开始时建议直接把 `status` 改为 `in_progress`。
108-
109-做完并推送后:
110-
111-- 如果等待整合,改为 `review`
112-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-019-conductor-http.md
+0, -100
  1@@ -1,100 +0,0 @@
  2----
  3-task_id: T-019
  4-title: Conductor 本地 HTTP 入口
  5-status: done
  6-branch: feat/T-019-conductor-http
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@458d7cf
  9-depends_on:
 10-  - T-015
 11-write_scope:
 12-  - apps/conductor-daemon/**
 13-updated_at: 2026-03-22
 14----
 15-
 16-# T-019 Conductor 本地 HTTP 入口
 17-
 18-## 目标
 19-
 20-把 `conductor-daemon` 从“只有 CLI/runtime 快照”推进到“具备本地只读 HTTP 探活入口”的程度,至少覆盖 `GET /healthz`、`GET /readyz`、`GET /rolez`。
 21-
 22-## 本任务包含
 23-
 24-- 在 `apps/conductor-daemon/**` 内实现最小本地 HTTP server
 25-- 暴露 `healthz` / `readyz` / `rolez`,必要时补 `GET /v1/runtime`
 26-- 把现有 runtime snapshot 接到这些只读接口
 27-- 为本地 HTTP server 补最小测试或冒烟验证
 28-
 29-## 本任务不包含
 30-
 31-- 修改 `packages/db/**`
 32-- 修改 control-api-worker
 33-- 修改 status-api
 34-- 完整任务调度器
 35-
 36-## 建议起始文件
 37-
 38-- `apps/conductor-daemon/src/index.ts`
 39-- `apps/conductor-daemon/src/index.test.js`
 40-
 41-## 交付物
 42-
 43-- 本地 loopback HTTP 探活入口
 44-- 清晰的 `healthz` / `readyz` / `rolez` 语义
 45-- launchd 或 Nginx 后续可复用的读取面
 46-
 47-## 验收
 48-
 49-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
 50-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
 51-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
 52-
 53-## 更新要求
 54-
 55-完成时更新 frontmatter 的:
 56-
 57-- `status`
 58-- `base_ref`
 59-- `updated_at`
 60-
 61-并补充下面这些内容:
 62-
 63-## files_changed
 64-
 65-- `apps/conductor-daemon/src/index.ts`
 66-- `apps/conductor-daemon/src/index.test.js`
 67-- `apps/conductor-daemon/src/node-shims.d.ts`
 68-- `apps/conductor-daemon/tsconfig.json`
 69-- `coordination/tasks/T-019-conductor-http.md`
 70-
 71-## commands_run
 72-
 73-- `git worktree add /Users/george/code/baa-conductor-T019 -b feat/T-019-conductor-http 458d7cf`
 74-- `npx --yes pnpm install`
 75-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
 76-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
 77-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
 78-- `git diff --check`
 79-
 80-## result
 81-
 82-- 已在 `apps/conductor-daemon` 内补上最小本地只读 HTTP server,基于 `BAA_CONDUCTOR_LOCAL_API` 暴露 `GET /healthz`、`GET /readyz`、`GET /rolez` 与 `GET /v1/runtime`,并限制为 loopback `http://` 地址。
 83-- 已把现有 runtime snapshot 接到 HTTP 读取面,`ConductorRuntime` 现在会随启动/停止一起管理本地 server 生命周期,并在快照中回填实际监听地址。
 84-- 已补测试覆盖降级态 `readyz/rolez` 语义与真实本地 HTTP 冒烟请求,`typecheck`、`build`、`node --test` 均通过。
 85-
 86-## risks
 87-
 88-- 当前 `/readyz` 的语义是“runtime 已启动且 lease 未降级”;后续如果要把本地 runs 恢复、调度器预热或更多依赖也纳入 readiness,需要再扩展判定。
 89-- 为避免越出本任务 `write_scope`,Node 内建模块类型声明目前以 `apps/conductor-daemon/src/node-shims.d.ts` 本地 shim 形式维护;如果仓库后续统一引入 `@types/node`,应顺手收敛这里的声明。
 90-
 91-## next_handoff
 92-
 93-- launchd、Nginx 或后续 status 相关整合可以直接复用 `http://127.0.0.1:4317/healthz`、`/readyz`、`/rolez`、`/v1/runtime` 作为 conductor 的本地读取面。
 94-- 如果后续需要更严格的运维判断,可在不改外部路由的前提下继续细化 `/readyz` 与 `/v1/runtime` 的字段。
 95-
 96-开始时建议直接把 `status` 改为 `in_progress`。
 97-
 98-做完并推送后:
 99-
100-- 如果等待整合,改为 `review`
101-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-020-status-host.md
+0, -107
  1@@ -1,107 +0,0 @@
  2----
  3-task_id: T-020
  4-title: Status API 本地宿主进程
  5-status: done
  6-branch: feat/T-020-status-host
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@458d7cf
  9-depends_on:
 10-  - T-017
 11-write_scope:
 12-  - apps/status-api/**
 13-updated_at: 2026-03-22
 14----
 15-
 16-# T-020 Status API 本地宿主进程
 17-
 18-## 目标
 19-
 20-把 `status-api` 从“可挂载运行时入口”推进到“带本地宿主进程或 node server adapter,可直接启动监听”的程度。
 21-
 22-## 本任务包含
 23-
 24-- 在 `apps/status-api/**` 内实现本地宿主进程或 node HTTP adapter
 25-- 把 `createStatusApiRuntime()` 真正挂到监听端口
 26-- 整理 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 的本地启动方式
 27-- 补最小启动/冒烟验证
 28-
 29-## 本任务不包含
 30-
 31-- 修改 conductor-daemon
 32-- 修改 control-api-worker
 33-- 真实部署到 launchd
 34-- D1 线上接入
 35-
 36-## 建议起始文件
 37-
 38-- `apps/status-api/src/index.ts`
 39-- `apps/status-api/src/runtime.ts`
 40-- `apps/status-api/src/service.ts`
 41-
 42-## 交付物
 43-
 44-- 可直接启动的 status-api 本地宿主进程
 45-- 明确的本地端口和启动接口
 46-- 后续 launchd 可直接复用的入口
 47-
 48-## 验收
 49-
 50-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
 51-- `npx --yes pnpm --filter @baa-conductor/status-api build`
 52-- 能说明本地如何启动和访问 `healthz` / `status` / `ui`
 53-
 54-## 更新要求
 55-
 56-完成时更新 frontmatter 的:
 57-
 58-- `status`
 59-- `base_ref`
 60-- `updated_at`
 61-
 62-并补充下面这些内容:
 63-
 64-## files_changed
 65-
 66-- `apps/status-api/package.json`
 67-- `apps/status-api/tsconfig.json`
 68-- `apps/status-api/src/index.ts`
 69-- `apps/status-api/src/cli.ts`
 70-- `apps/status-api/src/host.ts`
 71-- `apps/status-api/src/node-shims.d.ts`
 72-- `coordination/tasks/T-020-status-host.md`
 73-
 74-## commands_run
 75-
 76-- `npx --yes pnpm install`
 77-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
 78-- `npx --yes pnpm --filter @baa-conductor/status-api build`
 79-- `npx --yes pnpm --filter @baa-conductor/status-api smoke`
 80-- `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328`
 81-- `curl --silent --show-error http://127.0.0.1:4328/healthz`
 82-- `curl --silent --show-error http://127.0.0.1:4328/v1/status`
 83-- `curl --silent --show-error http://127.0.0.1:4328/v1/status/ui | sed -n '1,8p'`
 84-
 85-## result
 86-
 87-- 已在 `apps/status-api/**` 内补齐 Node 宿主层,新增本地 HTTP request listener 与 `startStatusApiServer()`,把 `createStatusApiRuntime()` 真正挂到监听端口。
 88-- 已补 `runStatusApiCli()` 与主入口直启逻辑,`node apps/status-api/dist/index.js` 现在默认监听 `127.0.0.1:4318`,可直接给 launchd 复用。
 89-- 已新增包内脚本:`pnpm --filter @baa-conductor/status-api serve`、`start`、`smoke`;本地可访问 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`。
 90-- 已补最小真实 HTTP 冒烟验证:`smoke` 会拉起临时端口并验证三条 GET 路由,另外也手动确认了 `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328` 的实际监听响应。
 91-
 92-## risks
 93-
 94-- 当前宿主进程默认仍使用 `StaticStatusSnapshotLoader`,返回的是本地空快照;真实 D1 / 控制平面接线仍需后续整合任务注入。
 95-- `dist/index.js` 的直启能力依赖当前统一 build 产物布局;如果后续全仓库再调整 `BAA_DIST_ENTRY` 或 shim 结构,需要同步验证直启检测逻辑。
 96-
 97-## next_handoff
 98-
 99-- 在后续整合中,把真实 snapshot loader 注入 `startStatusApiServer()` 或 CLI 启动路径,让 `/v1/status` 返回真实控制平面状态。
100-- 视 launchd/bootstrap 任务需要,在安装副本里补 `BAA_STATUS_API_HOST` / `BAA_STATUS_API_PORT` 等环境变量,或继续沿用默认 `127.0.0.1:4318`。
101-- 如果后续希望把 status-api 嵌入到更大的 Node 宿主里,可直接复用本任务新增的 request listener / server adapter,而不必再改 service/runtime 层。
102-
103-开始时建议直接把 `status` 改为 `in_progress`。
104-
105-做完并推送后:
106-
107-- 如果等待整合,改为 `review`
108-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-021-runtime-bootstrap.md
+0, -119
  1@@ -1,119 +0,0 @@
  2----
  3-task_id: T-021
  4-title: launchd 安装脚本与 Runtime Bootstrap
  5-status: done
  6-branch: feat/T-021-runtime-bootstrap
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@458d7cf
  9-depends_on:
 10-  - T-011
 11-  - T-013
 12-  - T-015
 13-  - T-017
 14-write_scope:
 15-  - ops/launchd/**
 16-  - docs/runtime/**
 17-  - scripts/runtime/**
 18-updated_at: 2026-03-22T01:08:59+0800
 19----
 20-
 21-# T-021 launchd 安装脚本与 Runtime Bootstrap
 22-
 23-## 目标
 24-
 25-把现有 launchd 模板和 runtime 文档推进到“有实际 bootstrap/install/check 脚本可执行”的程度,减少手工部署步骤。
 26-
 27-## 本任务包含
 28-
 29-- 在 `scripts/runtime/**` 下补 runtime 目录初始化脚本
 30-- 补 launchd 安装、重载、检查的辅助脚本
 31-- 让 `docs/runtime/**` 与脚本行为保持一致
 32-- 如有必要,小幅修正 `ops/launchd/**` 模板中的占位项说明
 33-
 34-## 本任务不包含
 35-
 36-- 修改 app 业务代码
 37-- 实际在本机加载 launchd 服务
 38-- 修改 Nginx 或 Cloudflare DNS
 39-
 40-## 建议起始文件
 41-
 42-- `docs/runtime/README.md`
 43-- `docs/runtime/launchd.md`
 44-- `docs/runtime/layout.md`
 45-- `ops/launchd/*.plist`
 46-- `scripts/runtime/`
 47-
 48-## 交付物
 49-
 50-- 可执行的 bootstrap/install/check 脚本
 51-- 与脚本一致的 runtime / launchd 文档
 52-- 更低摩擦的本地部署流程
 53-
 54-## 验收
 55-
 56-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist`
 57-- `plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist`
 58-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
 59-- 如果新增 shell 脚本:`bash -n scripts/runtime/*.sh`
 60-
 61-## 更新要求
 62-
 63-完成时更新 frontmatter 的:
 64-
 65-- `status`
 66-- `base_ref`
 67-- `updated_at`
 68-
 69-并补充下面这些内容:
 70-
 71-## files_changed
 72-
 73-- `coordination/tasks/T-021-runtime-bootstrap.md`
 74-- `docs/runtime/README.md`
 75-- `docs/runtime/environment.md`
 76-- `docs/runtime/launchd.md`
 77-- `docs/runtime/layout.md`
 78-- `ops/launchd/so.makefile.baa-conductor.plist`
 79-- `ops/launchd/so.makefile.baa-worker-runner.plist`
 80-- `ops/launchd/so.makefile.baa-status-api.plist`
 81-- `scripts/runtime/bootstrap.sh`
 82-- `scripts/runtime/check-launchd.sh`
 83-- `scripts/runtime/common.sh`
 84-- `scripts/runtime/install-launchd.sh`
 85-- `scripts/runtime/reload-launchd.sh`
 86-
 87-## commands_run
 88-
 89-- `npx --yes pnpm install`
 90-- `chmod +x scripts/runtime/bootstrap.sh scripts/runtime/install-launchd.sh scripts/runtime/reload-launchd.sh scripts/runtime/check-launchd.sh`
 91-- `bash -n scripts/runtime/*.sh`
 92-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist ops/launchd/so.makefile.baa-worker-runner.plist ops/launchd/so.makefile.baa-status-api.plist`
 93-- `git diff --check`
 94-- `./scripts/runtime/bootstrap.sh --repo-dir "$TMP_REPO"`
 95-- `./scripts/runtime/install-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
 96-- `./scripts/runtime/check-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
 97-- `./scripts/runtime/reload-launchd.sh --install-dir "$TMP_INSTALL" --all-services --dry-run`
 98-
 99-## result
100-
101-- 已新增 `scripts/runtime/bootstrap.sh`、`install-launchd.sh`、`check-launchd.sh`、`reload-launchd.sh` 与共享 helper,使 runtime 目录初始化、plist 安装副本渲染、静态校验、`launchctl` 重载都有可执行脚本。
102-- 已把 `docs/runtime/**` 改为以脚本驱动流程为准,补充 `--node`、`--repo-dir`、`--shared-token`、`--scope` 等输入约定,并明确默认只安装 `conductor`、其它模板需显式 opt-in。
103-- 已小幅修正 `ops/launchd/*.plist` 说明并补入 `BAA_STATE_DIR`,同时在临时 repo 上完成 bootstrap -> install -> check -> reload(dry-run) 的整链验证。
104-
105-## risks
106-
107-- 本任务只验证了 plist 渲染、静态检查和 dry-run `launchctl` 命令,没有在本机真实 `bootstrap` 任何 launchd 服务;实际目标机上仍需再做一次权限和 domain 验证。
108-- `worker-runner` 与 `status-api` 模板现在可以被脚本渲染和校验,但是否作为常驻服务启用仍取决于各自宿主进程接线进度;默认流程因此只安装 `conductor`。
109-
110-## next_handoff
111-
112-- 在真实 `mini` / `mac` 节点上按文档顺序执行 `bootstrap.sh`、`npx --yes pnpm -r build`、`install-launchd.sh`、`check-launchd.sh`,确认安装副本和 runtime 目录都落在预期路径。
113-- 真正准备加载服务时,先运行 `reload-launchd.sh --dry-run` 复核命令;确认无误后再去掉 `--dry-run`。若后续 runner/status 宿主进程落地,可再显式加 `--service worker-runner` 或 `--service status-api`。
114-
115-开始时建议直接把 `status` 改为 `in_progress`。
116-
117-做完并推送后:
118-
119-- 如果等待整合,改为 `review`
120-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-022-ops-automation.md
+0, -117
  1@@ -1,117 +0,0 @@
  2----
  3-task_id: T-022
  4-title: Nginx 与 Cloudflare DNS 自动化
  5-status: done
  6-branch: feat/T-022-ops-automation
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@458d7cf
  9-depends_on:
 10-  - T-008
 11-write_scope:
 12-  - ops/nginx/**
 13-  - docs/ops/**
 14-  - scripts/ops/**
 15-updated_at: 2026-03-22
 16----
 17-
 18-# T-022 Nginx 与 Cloudflare DNS 自动化
 19-
 20-## 目标
 21-
 22-把当前的 Nginx 配置和 Cloudflare DNS 管理推进到“有脚本辅助同步和部署”的程度,减少手工改记录、手工改配置的步骤。
 23-
 24-## 本任务包含
 25-
 26-- 在 `scripts/ops/**` 下补 Cloudflare DNS 管理脚本
 27-- 把当前公网域名与内网 Tailscale `100.x` 的关系写成可执行模板
 28-- 补 Nginx 部署/校验辅助脚本或分发模板
 29-- 收口 `docs/ops/**` 中的操作步骤
 30-
 31-## 本任务不包含
 32-
 33-- 实际修改线上 DNS 记录
 34-- 实际重载线上 Nginx
 35-- 修改 control-api-worker、conductor-daemon、status-api 代码
 36-
 37-## 建议起始文件
 38-
 39-- `ops/nginx/baa-conductor.conf`
 40-- `ops/nginx/includes/*.conf`
 41-- `docs/ops/README.md`
 42-- `scripts/ops/`
 43-
 44-## 交付物
 45-
 46-- DNS 辅助脚本
 47-- Nginx 校验/部署辅助脚本或模板
 48-- 对应运维文档收口
 49-
 50-## 验收
 51-
 52-- 如果新增 shell 脚本:`bash -n scripts/ops/*.sh`
 53-- `git diff --check`
 54-- 文档与脚本中的域名 / Tailscale `100.x` 方案一致
 55-
 56-## 更新要求
 57-
 58-完成时更新 frontmatter 的:
 59-
 60-- `status`
 61-- `base_ref`
 62-- `updated_at`
 63-
 64-并补充下面这些内容:
 65-
 66-## files_changed
 67-
 68-- `coordination/tasks/T-022-ops-automation.md`
 69-- `docs/ops/README.md`
 70-- `ops/nginx/templates/baa-conductor.conf.template`
 71-- `ops/nginx/templates/includes/direct-node-auth.conf.template`
 72-- `scripts/ops/baa-conductor.env.example`
 73-- `scripts/ops/cloudflare-dns-plan.mjs`
 74-- `scripts/ops/cloudflare-dns-plan.sh`
 75-- `scripts/ops/lib/ops-config.mjs`
 76-- `scripts/ops/nginx-sync-plan.mjs`
 77-- `scripts/ops/nginx-sync-plan.sh`
 78-
 79-## commands_run
 80-
 81-- `git worktree add /Users/george/code/baa-conductor-T022 -b feat/T-022-ops-automation 458d7cf`
 82-- `npx --yes pnpm install`
 83-- `chmod +x scripts/ops/*.sh scripts/ops/*.mjs`
 84-- `bash -n scripts/ops/*.sh`
 85-- `node --check scripts/ops/cloudflare-dns-plan.mjs`
 86-- `node --check scripts/ops/nginx-sync-plan.mjs`
 87-- `node --check scripts/ops/lib/ops-config.mjs`
 88-- `scripts/ops/cloudflare-dns-plan.sh --env scripts/ops/baa-conductor.env.example`
 89-- `scripts/ops/nginx-sync-plan.sh --env scripts/ops/baa-conductor.env.example --check-repo --bundle-dir .tmp/ops/baa-conductor-nginx`
 90-- `git diff --check`
 91-- `git commit -m "feat(ops): add nginx and cloudflare dns automation helpers"`
 92-- `git push -u origin feat/T-022-ops-automation`
 93-
 94-## result
 95-
 96-- 新增 `scripts/ops/baa-conductor.env.example`,把公网域名、VPS 公网 IP、Tailscale `100.x` 回源和 Nginx 安装路径收口到一份可执行 inventory 模板
 97-- 新增 `scripts/ops/cloudflare-dns-plan.{mjs,sh}`,可渲染目标 DNS 记录、可选用 Cloudflare GET API 对比现网,并输出预览用的 `curl` shell 脚本,但默认不写线上 DNS
 98-- 新增 `scripts/ops/nginx-sync-plan.{mjs,sh}` 与 `ops/nginx/templates/**`,可按 inventory 渲染 Nginx 配置、检查仓库默认配置是否漂移,并打出包含 `deploy-on-vps.sh` 的部署 bundle
 99-- `docs/ops/README.md` 已改为 inventory 驱动的运维流程说明,覆盖 DNS 计划、Nginx bundle、VPS 分发、证书与验证步骤
100-
101-## risks
102-
103-- 本次没有连接真实 Cloudflare zone;`--fetch-current` 和预览 shell 仍需在填入真实 Zone ID / token 后做一次人工核对
104-- 本次没有在真实 VPS 上执行 `deploy-on-vps.sh`、`nginx -t` 或 `systemctl reload nginx`;证书路径、权限和 systemd 服务名仍需线上确认
105-- 真实 inventory 预计放在仓库外,若运维人员绕开 inventory 手工改 Nginx 或 DNS,模板与线上状态仍可能漂移
106-
107-## next_handoff
108-
109-- 在仓库外复制并填写 `scripts/ops/baa-conductor.env.example`,补上真实 VPS 公网 IP、Cloudflare Zone ID 与证书路径
110-- 导出 `CLOUDFLARE_API_TOKEN` 后运行 `scripts/ops/cloudflare-dns-plan.sh --fetch-current --emit-shell ...`,审阅差异和预览脚本
111-- 运行 `scripts/ops/nginx-sync-plan.sh --env <real-env> --bundle-dir <dir>`,把 bundle 分发到 VPS,先执行 `sudo ./deploy-on-vps.sh`,确认 `nginx -t` 后再显式执行 `sudo ./deploy-on-vps.sh --reload`
112-
113-开始时建议直接把 `status` 改为 `in_progress`。
114-
115-做完并推送后:
116-
117-- 如果等待整合,改为 `review`
118-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-023-control-api-smoke.md
+0, -112
  1@@ -1,112 +0,0 @@
  2----
  3-task_id: T-023
  4-title: Control API 本地 D1 与 smoke
  5-status: done
  6-branch: feat/T-023-control-api-smoke
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@6505a31
  9-depends_on:
 10-  - T-018
 11-write_scope:
 12-  - apps/control-api-worker/**
 13-  - tests/control-api/**
 14-  - scripts/cloudflare/**
 15-updated_at: 2026-03-22T02:16:15+0800
 16----
 17-
 18-# T-023 Control API 本地 D1 与 smoke
 19-
 20-## 目标
 21-
 22-把 `control-api-worker` 从“有 Worker / D1 配置模板”推进到“能在本地做最小 D1 绑定与 smoke 验证”的程度。
 23-
 24-## 本任务包含
 25-
 26-- 在 `apps/control-api-worker/**` 内补本地 dev / smoke 入口
 27-- 在 `tests/control-api/**` 下补最小 smoke 或集成测试
 28-- 如有需要,在 `scripts/cloudflare/**` 下补本地准备脚本
 29-- 验证 control-api 在本地 D1 / mock D1 条件下的最小读写闭环
 30-
 31-## 本任务不包含
 32-
 33-- 修改 conductor-daemon
 34-- 修改 status-api
 35-- 真实线上 Cloudflare deploy
 36-- 修改 `packages/db/**`
 37-
 38-## 建议起始文件
 39-
 40-- `apps/control-api-worker/wrangler.jsonc`
 41-- `apps/control-api-worker/src/index.ts`
 42-- `apps/control-api-worker/src/runtime.ts`
 43-- `tests/control-api/`
 44-
 45-## 交付物
 46-
 47-- 本地 smoke 路径
 48-- 最小 control-api 集成测试
 49-- 更明确的本地 D1 准备方式
 50-
 51-## 验收
 52-
 53-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 54-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 55-- 能说明如何在本地跑 control-api smoke
 56-
 57-## 更新要求
 58-
 59-完成时更新 frontmatter 的:
 60-
 61-- `status`
 62-- `base_ref`
 63-- `updated_at`
 64-
 65-并补充下面这些内容:
 66-
 67-## files_changed
 68-
 69-- `coordination/tasks/T-023-control-api-smoke.md`
 70-- `apps/control-api-worker/.dev.vars.example`
 71-- `apps/control-api-worker/package.json`
 72-- `apps/control-api-worker/local/sqlite-d1.mjs`
 73-- `apps/control-api-worker/local/harness.mjs`
 74-- `apps/control-api-worker/local/dev.mjs`
 75-- `apps/control-api-worker/local/smoke.mjs`
 76-- `scripts/cloudflare/prepare-control-api-local-db.mjs`
 77-- `tests/control-api/control-api-smoke.test.mjs`
 78-
 79-## commands_run
 80-
 81-- `git worktree add /Users/george/code/baa-conductor-T023 -b feat/T-023-control-api-smoke 6505a31`
 82-- `npx --yes pnpm install`
 83-- `node --check apps/control-api-worker/local/sqlite-d1.mjs`
 84-- `node --check apps/control-api-worker/local/harness.mjs`
 85-- `node --check apps/control-api-worker/local/dev.mjs`
 86-- `node --check apps/control-api-worker/local/smoke.mjs`
 87-- `node --check scripts/cloudflare/prepare-control-api-local-db.mjs`
 88-- `node --check tests/control-api/control-api-smoke.test.mjs`
 89-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
 90-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
 91-- `npx --yes pnpm --filter @baa-conductor/control-api-worker db:prepare:local`
 92-- `npx --yes pnpm --filter @baa-conductor/control-api-worker smoke -- --db .wrangler/state/local-control-api.sqlite --resetDb`
 93-- `npx --yes pnpm --filter @baa-conductor/control-api-worker test:integration`
 94-- `node local/dev.mjs --smoke --resetDb`
 95-- `git diff --check`
 96-
 97-## result
 98-
 99-- 在 `apps/control-api-worker/local/**` 新增了 SQLite-backed D1 shim、本地 harness、`dev` server 和 `smoke` runner;`pnpm --filter @baa-conductor/control-api-worker dev` 现在会先构建,再在本地起一个可直接用 Bearer token 访问的 control-api。
100-- 在 `tests/control-api/control-api-smoke.test.mjs` 增加了最小集成 smoke,覆盖 `system.pause -> system.state`、`leader.acquire -> system.state`、`tasks.create -> tasks.read` 三条最小读写闭环。
101-- 在 `scripts/cloudflare/prepare-control-api-local-db.mjs` 增加了本地 DB 准备脚本,并在包脚本中暴露 `db:prepare:local`、`smoke`、`test:integration` 入口,方便后续联调复用。
102-
103-## risks
104-
105-- 当前本地验证使用的是 Node `node:sqlite` 驱动实现的 D1-compatible shim,而不是 `wrangler dev` 内建的真实本地 D1 运行时;SQL 行为与当前仓库 schema 一致,但仍可能与 Cloudflare 边缘环境存在细小差异。
106-- 本次 smoke 聚焦已经实现的读写路由;`tasks.plan`、`tasks.claim`、`steps.*`、`tasks.logs.read`、`runs.read` 这些仍保持占位或未在测试中覆盖。
107-- `dev` 入口是 Node HTTP wrapper,不包含 Cloudflare 特有的 request metadata 或 Worker 生命周期细节;如果后续要验证这些差异,仍需单独补 wrangler-local smoke。
108-
109-## next_handoff
110-
111-- 后续如果要把 control-api 纳入 `T-024` 端到端 smoke,可直接复用 `pnpm --filter @baa-conductor/control-api-worker dev` 和 `tests/control-api/control-api-smoke.test.mjs` 里的本地 harness。
112-- 如果需要更贴近 Cloudflare 本地运行时,再补一层 `wrangler dev --local` / `wrangler d1` 路径,把现在的 SQLite shim smoke 与 wrangler smoke 并排保留。
113-- 若后续开始实现 `tasks.plan`、`tasks.claim` 或 `steps.*` 的 durable 写入,应沿用当前 harness,把新增路由接到同一套本地 D1 smoke 里继续扩展。
D coordination/tasks/done/T-024-e2e-smoke.md
+0, -110
  1@@ -1,110 +0,0 @@
  2----
  3-task_id: T-024
  4-title: 端到端 smoke harness
  5-status: done
  6-branch: feat/T-024-e2e-smoke
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@288c748
  9-depends_on:
 10-  - T-019
 11-  - T-020
 12-  - T-021
 13-  - T-022
 14-write_scope:
 15-  - tests/e2e/**
 16-  - scripts/smoke/**
 17-updated_at: 2026-03-22T02:16:15+0800
 18----
 19-
 20-# T-024 端到端 smoke harness
 21-
 22-## 目标
 23-
 24-补一套可重复运行的端到端 smoke harness,把本地起服务、探活、状态读取和最小主备流程串起来。
 25-
 26-## 本任务包含
 27-
 28-- 在 `tests/e2e/**` 下补最小端到端 smoke
 29-- 在 `scripts/smoke/**` 下补启动 / 检查辅助脚本
 30-- 把 conductor、status-api、control-api 的最小读取面串起来
 31-- 输出可重复执行的 smoke 流程
 32-
 33-## 本任务不包含
 34-
 35-- 修改 `apps/control-api-worker/**`
 36-- 修改 `apps/conductor-daemon/**`
 37-- 修改 `apps/status-api/**`
 38-- 真实线上部署
 39-
 40-## 建议起始文件
 41-
 42-- `tests/e2e/`
 43-- `scripts/smoke/`
 44-- `docs/ops/README.md`
 45-
 46-## 交付物
 47-
 48-- 本地 smoke harness
 49-- 一套明确的启动与验证步骤
 50-- 可供后续 failover 演练复用的基础流程
 51-
 52-## 验收
 53-
 54-- 如果新增 shell 脚本:`bash -n scripts/smoke/*.sh`
 55-- `git diff --check`
 56-- 能说明如何一键或半自动执行 smoke
 57-
 58-## 更新要求
 59-
 60-完成时更新 frontmatter 的:
 61-
 62-- `status`
 63-- `base_ref`
 64-- `updated_at`
 65-
 66-并补充下面这些内容:
 67-
 68-## files_changed
 69-
 70-- coordination/tasks/T-024-e2e-smoke.md
 71-- scripts/smoke/README.md
 72-- scripts/smoke/check-stack.sh
 73-- scripts/smoke/control-api-local.mjs
 74-- scripts/smoke/d1-sqlite.mjs
 75-- scripts/smoke/run-e2e.sh
 76-- scripts/smoke/stack-cli.mjs
 77-- scripts/smoke/start-stack.sh
 78-- scripts/smoke/status-api-local.mjs
 79-- scripts/smoke/stop-stack.sh
 80-- tests/e2e/smoke.test.mjs
 81-
 82-## commands_run
 83-
 84-- npx --yes pnpm install
 85-- node --check scripts/smoke/d1-sqlite.mjs
 86-- node --check scripts/smoke/control-api-local.mjs
 87-- node --check scripts/smoke/status-api-local.mjs
 88-- node --check scripts/smoke/stack-cli.mjs
 89-- bash -n scripts/smoke/*.sh
 90-- bash scripts/smoke/run-e2e.sh --json
 91-- node --test tests/e2e/smoke.test.mjs
 92-- bash scripts/smoke/start-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check
 93-- bash scripts/smoke/check-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check --expected-leader smoke-mini
 94-- bash scripts/smoke/stop-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check
 95-- git diff --check
 96-
 97-## result
 98-
 99-- 新增本地 smoke stack CLI 和 shell 包装脚本,可一键或分步启动、检查、停止 control-api、主备 conductor、status-api。
100-- 新增基于 SQLite 的 smoke-only D1 适配层,用于本地共享 control-api 和 status-api 的最小 durable 视图。
101-- 新增端到端 smoke 测试,覆盖探活、`/v1/system/state`、`/v1/status`、queued task 可见性和主备 failover。
102-
103-## risks
104-
105-- `scripts/smoke/d1-sqlite.mjs` 是本地 smoke 适配层,不等价于真实 Cloudflare D1 / Wrangler 运行时语义。
106-- 当前 smoke 聚焦最小读取面和 lease failover,不覆盖 task claim、worker 执行和真实线上部署链路。
107-
108-## next_handoff
109-
110-- 后续任务可直接复用 `bash scripts/smoke/run-e2e.sh --json` 作为本地回归入口。
111-- T-025 可复用 `start-stack.sh` / `check-stack.sh` / `stop-stack.sh` 和 `stack-cli.mjs` 的状态目录结构继续扩展 failover rehearsal。
D coordination/tasks/done/T-025-failover-rehearsal.md
+0, -100
  1@@ -1,100 +0,0 @@
  2----
  3-task_id: T-025
  4-title: Failover rehearsal 与 Runbook
  5-status: done
  6-branch: feat/T-025-failover-rehearsal
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@6505a31
  9-depends_on:
 10-  - T-019
 11-  - T-021
 12-  - T-022
 13-write_scope:
 14-  - docs/ops/**
 15-  - scripts/failover/**
 16-updated_at: 2026-03-22T02:16:15+0800
 17----
 18-
 19-# T-025 Failover rehearsal 与 Runbook
 20-
 21-## 目标
 22-
 23-把当前主备设计推进到“有可执行 rehearsal 步骤和清晰运维 runbook”的程度。
 24-
 25-## 本任务包含
 26-
 27-- 在 `scripts/failover/**` 下补 failover rehearsal 辅助脚本
 28-- 在 `docs/ops/**` 下补 planned failover / emergency failover / switchback runbook
 29-- 把 Tailscale `100.x`、公网域名、launchd、Nginx 的配合关系写清楚
 30-
 31-## 本任务不包含
 32-
 33-- 修改 app 业务代码
 34-- 真实执行线上 failover
 35-- 修改 `baa-firefox`
 36-
 37-## 建议起始文件
 38-
 39-- `docs/ops/README.md`
 40-- `scripts/failover/`
 41-
 42-## 交付物
 43-
 44-- 可执行或半自动的 rehearsal 辅助脚本
 45-- 清晰的 failover 运维手册
 46-
 47-## 验收
 48-
 49-- 如果新增 shell 脚本:`bash -n scripts/failover/*.sh`
 50-- `git diff --check`
 51-- 文档里明确 planned / emergency / switchback 三种路径
 52-
 53-## 更新要求
 54-
 55-完成时更新 frontmatter 的:
 56-
 57-- `status`
 58-- `base_ref`
 59-- `updated_at`
 60-
 61-并补充下面这些内容:
 62-
 63-## files_changed
 64-
 65-- `coordination/tasks/T-025-failover-rehearsal.md`
 66-- `docs/ops/README.md`
 67-- `docs/ops/failover-topology.md`
 68-- `docs/ops/planned-failover.md`
 69-- `docs/ops/emergency-failover.md`
 70-- `docs/ops/switchback.md`
 71-- `scripts/failover/common.sh`
 72-- `scripts/failover/print-topology.sh`
 73-- `scripts/failover/rehearsal-check.sh`
 74-- `scripts/failover/print-checklist.sh`
 75-
 76-## commands_run
 77-
 78-- `npx --yes pnpm install`
 79-- `chmod +x scripts/failover/*.sh`
 80-- `bash -n scripts/failover/*.sh`
 81-- `./scripts/failover/print-topology.sh --env scripts/ops/baa-conductor.env.example`
 82-- `./scripts/failover/print-checklist.sh --scenario planned --env scripts/ops/baa-conductor.env.example`
 83-- `./scripts/failover/rehearsal-check.sh --env scripts/ops/baa-conductor.env.example --skip-public --skip-control-api`
 84-- `git diff --check`
 85-
 86-## result
 87-
 88-- 新增 `scripts/failover` 只读辅助脚本,用于输出主备拓扑、生成场景化 checklist,并对 public/direct/control-api 做 rehearsal 校验。
 89-- 在 `docs/ops` 下补齐 failover topology、planned failover、emergency failover、switchback 四份文档。
 90-- runbook 明确写清 Cloudflare DNS 固定到 VPS、VPS Nginx 回源 Tailscale `100.x`、节点 `launchd` 控制本地 conductor 存活,以及当前 Nginx 只按连通性而非 lease 感知切换。
 91-
 92-## risks
 93-
 94-- 当前 `GET /v1/system/state` 仍只返回扁平 `holder_id/mode/term/lease_expires_at`,脚本只能通过 `holder_id` 前缀推断 leader 节点。
 95-- 当前没有真正的 `promote/demote` 维护接口;planned failover 和 switchback 仍依赖 `pause/drain` 加本机 `launchctl bootout/reload`。
 96-- 如果 mini 仍可达但逻辑上不该接流量,公网入口可能仍命中 mini;emergency runbook 已记录这种情况下需要 VPS Nginx 热修。
 97-
 98-## next_handoff
 99-
100-- 在真实节点或 staging 上按 runbook 做一次 dry-run / rehearsal,确认 `mini`、`mac`、VPS 三侧命令、权限和路径与文档一致。
101-- 后续可补 `system.state` 的 leader host 字段,以及真正的 maintenance `promote/demote` API,减少对人工 `launchctl` 与 Nginx 热修的依赖。
D coordination/tasks/done/T-026-firefox-integration.md
+0, -107
  1@@ -1,107 +0,0 @@
  2----
  3-task_id: T-026
  4-title: baa-firefox 实际接线
  5-status: done
  6-branch: feat/T-026-firefox-integration
  7-repo: /Users/george/code/baa/baa-firefox
  8-base_ref: main@045b1a9
  9-depends_on:
 10-  - T-018
 11-  - T-019
 12-  - T-020
 13-write_scope:
 14-  - background.js
 15-  - controller.html
 16-  - controller.js
 17-  - content-script.js
 18-  - page-interceptor.js
 19-  - package.json
 20-  - package-lock.json
 21-  - docs/**
 22-updated_at: 2026-03-22T02:16:15+0800
 23----
 24-
 25-# T-026 baa-firefox 实际接线
 26-
 27-## 目标
 28-
 29-把 `baa-firefox` 从“已有协议与大部分基础能力”推进到“真正能读取 conductor 状态并执行 pause / resume / drain”的程度。
 30-
 31-## 本任务包含
 32-
 33-- 在 `baa-firefox` 仓库里把插件接到 `baa-conductor` 的控制面
 34-- 读取 system state、leader、queue 基本信息
 35-- 提供 pause / resume / drain 的实际调用
 36-- 给可见 control 面板或 Claude 页面加最小可见入口
 37-
 38-## 本任务不包含
 39-
 40-- 修改 `baa-conductor` 仓库代码
 41-- 重做整个插件架构
 42-- 自动化完整浏览器工作流
 43-
 44-## 建议起始文件
 45-
 46-- `/Users/george/code/baa/baa-firefox/background.js`
 47-- `/Users/george/code/baa/baa-firefox/controller.html`
 48-- `/Users/george/code/baa/baa-firefox/controller.js`
 49-- `/Users/george/code/baa/baa-firefox/content-script.js`
 50-
 51-## 交付物
 52-
 53-- 插件真实读取 `system/state`
 54-- 插件真实发送 `pause` / `resume` / `drain`
 55-- 最小 UI 与状态文案
 56-
 57-## 验收
 58-
 59-- `baa-firefox` 仓库内的最小本地构建或静态校验通过
 60-- 能说明如何在浏览器里验证控制动作
 61-
 62-## 更新要求
 63-
 64-完成时更新 frontmatter 的:
 65-
 66-- `status`
 67-- `base_ref`
 68-- `updated_at`
 69-
 70-并补充下面这些内容:
 71-
 72-## files_changed
 73-
 74-- `background.js`
 75-- `content-script.js`
 76-- `controller.html`
 77-- `controller.js`
 78-- `docs/conductor-control.md`
 79-- `package.json`
 80-- `package-lock.json`
 81-- `page-interceptor.js`
 82-- `.gitignore`
 83-
 84-## commands_run
 85-
 86-- `npm install`
 87-- `node --check background.js`
 88-- `node --check controller.js`
 89-- `node --check content-script.js`
 90-- `node --check page-interceptor.js`
 91-
 92-## result
 93-
 94-- Firefox 插件已经接上 `baa-conductor` control plane,可读取 `/v1/system/state`,并实际发送 `pause` / `resume` / `drain`。
 95-- `controller.html` / `controller.js` 新增了 control API base URL、bearer token、状态快照展示和动作按钮。
 96-- `background.js` 会根据最新 control snapshot 更新 badge;`content-script.js` 在 Claude 页面右下角提供最小浮层入口。
 97-- 新增 [`docs/conductor-control.md`](/Users/george/code/baa/baa-firefox/docs/conductor-control.md) 说明接线方式、状态字段和当前限制。
 98-
 99-## risks
100-
101-- 本次没有修改 `manifest.json`,因此 Control API 仍需要落在当前扩展 CSP 允许的地址范围内。
102-- 当前最稳妥的部署方式仍是把 conductor control API 暴露为与现有本地桥接地址同源的入口,而不是任意远端地址。
103-- 这次只做了静态 JS 校验,没有做完整浏览器内交互回归。
104-
105-## next_handoff
106-
107-- 待 `T-024` 端到端 smoke 补齐后,把 Firefox 控制动作纳入 smoke / runbook。
108-- 合并 `baa-firefox` 分支前,建议再做一次真实浏览器点击验证,确认 badge、浮层和 controller 页面状态同步正常。
D coordination/tasks/done/T-027-node-verification.md
+0, -94
 1@@ -1,94 +0,0 @@
 2----
 3-task_id: T-027
 4-title: launchd 节点验证与 On-Node 检查
 5-status: done
 6-branch: feat/T-027-node-verification
 7-repo: /Users/george/code/baa-conductor
 8-base_ref: main@6505a31
 9-depends_on:
10-  - T-019
11-  - T-020
12-  - T-021
13-write_scope:
14-  - docs/runtime/**
15-  - scripts/runtime/**
16-updated_at: 2026-03-22T02:16:15+0800
17----
18-
19-# T-027 launchd 节点验证与 On-Node 检查
20-
21-## 目标
22-
23-把当前 launchd/bootstrap 脚本推进到“有更贴近真实节点的 on-node 检查和验证流程”的程度。
24-
25-## 本任务包含
26-
27-- 在 `scripts/runtime/**` 下补 on-node 检查脚本
28-- 在 `docs/runtime/**` 下补 mini / mac 节点验证步骤
29-- 覆盖本地端口、`healthz/readyz/rolez`、status-api 宿主进程、日志路径等检查点
30-
31-## 本任务不包含
32-
33-- 修改 app 业务代码
34-- 真实加载 launchd 服务
35-- 修改 Nginx / DNS
36-
37-## 建议起始文件
38-
39-- `docs/runtime/launchd.md`
40-- `docs/runtime/README.md`
41-- `scripts/runtime/check-launchd.sh`
42-
43-## 交付物
44-
45-- 更接近真实节点操作的验证脚本
46-- 明确的 mini / mac 验证清单
47-
48-## 验收
49-
50-- 如果新增 shell 脚本:`bash -n scripts/runtime/*.sh`
51-- `git diff --check`
52-- 文档里明确 mini 与 mac 的 on-node 检查顺序
53-
54-## 更新要求
55-
56-完成时更新 frontmatter 的:
57-
58-- `status`
59-- `base_ref`
60-- `updated_at`
61-
62-并补充下面这些内容:
63-
64-## files_changed
65-
66-- `coordination/tasks/T-027-node-verification.md`
67-- `docs/runtime/README.md`
68-- `docs/runtime/launchd.md`
69-- `docs/runtime/node-verification.md`
70-- `scripts/runtime/common.sh`
71-- `scripts/runtime/check-node.sh`
72-
73-## commands_run
74-
75-- `npx --yes pnpm install`
76-- `bash -n scripts/runtime/*.sh`
77-- `git diff --check`
78-- `scripts/runtime/check-node.sh --help`
79-- `scripts/runtime/check-node.sh --repo-dir <tmp_repo> --node mini --service conductor --service status-api --skip-static-check --local-api-base http://127.0.0.1:4417 --status-api-base http://127.0.0.1:4418 --expected-rolez leader`
80-
81-## result
82-
83-- 新增 `scripts/runtime/check-node.sh`,把节点验证推进到运行态层:覆盖 runtime 静态校验复用、本地端口、conductor `/healthz` `/readyz` `/rolez`、status-api 宿主进程与 `/healthz` `/v1/status` `/v1/status/ui`、以及 launchd 日志文件存在性。
84-- `scripts/runtime/common.sh` 补了 status-api 默认地址、on-node 默认服务集合和进程匹配辅助逻辑,避免节点约定散落在多个脚本里。
85-- `docs/runtime/README.md`、`docs/runtime/launchd.md`、`docs/runtime/node-verification.md` 明确了 mini/mac 的静态检查顺序、on-node 检查顺序,以及 steady-state 下 `mini=leader`、`mac=standby` 的 `rolez` 预期。
86-
87-## risks
88-
89-- 没有在真实 `mini` / `mac` 节点上执行 `--check-loaded`,因此 `launchctl print gui/...` / `launchctl print system/...` 只完成了脚本级接线,没有做实机验证。
90-- `status-api /v1/status` 目前按返回体中包含 `"ok": true` 做断言;如果后续响应格式改成不同 JSON 序列化风格,脚本可能需要同步微调。
91-
92-## next_handoff
93-
94-- 在真实 `mini` 与 `mac` 节点上各跑一次 `scripts/runtime/check-node.sh --check-loaded ...`,确认 launchd 域、日志文件、端口和探针都与文档一致。
95-- 如果某个节点不常驻 `status-api`,在落地时显式使用 `--service conductor`,并确认运维文档是否需要额外区分“仅 conductor 节点”和“conductor + status-api 节点”。
D coordination/tasks/done/T-028-real-rollout.md
+0, -154
  1@@ -1,154 +0,0 @@
  2----
  3-task_id: T-028
  4-title: 真实 Cloudflare / VPS 上线执行
  5-status: done
  6-branch: feat/T-028-real-rollout-v2
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@be74d58
  9-depends_on:
 10-  - T-018
 11-  - T-019
 12-  - T-020
 13-  - T-021
 14-  - T-022
 15-  - T-023
 16-  - T-024
 17-  - T-025
 18-  - T-027
 19-write_scope:
 20-  - docs/ops/**
 21-  - docs/runtime/**
 22-  - ops/nginx/**
 23-  - apps/control-api-worker/**
 24-  - scripts/ops/**
 25-  - scripts/runtime/**
 26-updated_at: 2026-03-22T03:32:57+0800
 27----
 28-
 29-# T-028 真实 Cloudflare / VPS 上线执行
 30-
 31-## 目标
 32-
 33-把当前已经完成的本地联调骨架真正部署到 Cloudflare、VPS、mini、mac,并形成一套可复查的上线记录。
 34-
 35-## 本任务包含
 36-
 37-- 使用当前 Cloudflare token / wrangler / D1 配置完成 control-api 真实部署
 38-- 使用当前 DNS 脚本或命令把二级域名切到预期入口
 39-- 在 VPS 上落地 Nginx 配置并完成校验 / reload
 40-- 在 mini / mac 上落地 runtime 目录、launchd 安装与基础探活
 41-- 把真实环境 URL、端口、角色、检查结果写回文档与任务卡
 42-
 43-## 本任务不包含
 44-
 45-- 重写应用架构
 46-- 修改 `baa-firefox` 仓库
 47-- 长时间稳定性结论
 48-
 49-## 建议起始文件
 50-
 51-- `docs/ops/README.md`
 52-- `docs/runtime/README.md`
 53-- `ops/nginx/baa-conductor.conf`
 54-- `apps/control-api-worker/wrangler.jsonc`
 55-- `scripts/ops/cloudflare-dns-plan.sh`
 56-- `scripts/runtime/install-launchd.sh`
 57-
 58-## 交付物
 59-
 60-- 真实可访问的 control-api / status-api / conductor 入口
 61-- VPS、mini、mac 的上线执行记录
 62-- 更新后的运维文档与上线注意事项
 63-
 64-## 验收
 65-
 66-- 能说明 control-api 的真实外网入口和至少一个内网入口
 67-- 能说明 VPS 上 Nginx 已完成校验与 reload
 68-- 能说明 mini / mac 节点至少完成一次 on-node 探活
 69-- `git diff --check`
 70-
 71-## 更新要求
 72-
 73-完成时更新 frontmatter 的:
 74-
 75-- `status`
 76-- `base_ref`
 77-- `updated_at`
 78-
 79-并补充下面这些内容:
 80-
 81-## files_changed
 82-
 83-- `coordination/tasks/T-028-real-rollout.md`
 84-- `scripts/runtime/check-node.sh`
 85-- `docs/runtime/launchd.md`
 86-- `docs/runtime/node-verification.md`
 87-- `docs/runtime/real-rollout-2026-03-22.md`
 88-- `docs/ops/README.md`
 89-- `docs/ops/real-rollout-2026-03-22.md`
 90-
 91-## commands_run
 92-
 93-- 本地干净 worktree:`git -C /Users/george/code/baa-conductor-main-merge worktree add -b feat/T-028-real-rollout-v2 /Users/george/code/baa-conductor-T028-v2 be74d585251f20ff5bd74ae17f6f6b011ff0bd34`
 94-- 本地新 worktree 首命令:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
 95-- 本地构建:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
 96-- `mini` 本机:`./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
 97-- `mini` 本机:`set -a; source /Users/george/.config/baa-conductor/control-api-worker.secrets.env; set +a; ./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
 98-- `mini` 本机:`./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
 99-- `mini` 本机:`./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
100-- `mini` 本机:`./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
101-- 本地到双节点探活:`curl http://100.71.210.78:4317/healthz`、`curl http://100.71.210.78:4318/healthz`、`curl http://100.112.239.13:4317/healthz`、`curl http://100.112.239.13:4318/healthz`
102-- 同步到 `mac`:`rsync -az --delete --exclude '.git' --exclude 'node_modules' /Users/george/code/baa-conductor-T028-v2/ george@100.112.239.13:/Users/george/code/baa-conductor-T028-v2/`
103-- `mac` 远端:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
104-- `mac` 远端:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
105-- `mac` 远端:`./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
106-- `mac` 远端:`set -a; source /Users/george/.config/baa-conductor/control-api-worker.secrets.env; set +a; ./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
107-- `mac` 远端:`./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
108-- `mac` 远端:`./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
109-- `mac` 远端:`./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded`
110-- 生成 DNS / Nginx 计划:`scripts/ops/cloudflare-dns-plan.sh --env /Users/george/.config/baa-conductor/ops.env --fetch-current --emit-shell .tmp/ops/cloudflare-dns-preview.sh --output .tmp/ops/cloudflare-dns-plan.json`、`scripts/ops/nginx-sync-plan.sh --env /Users/george/.config/baa-conductor/ops.env --bundle-dir .tmp/ops/baa-conductor-nginx`
111-- VPS 基线:`ssh -p 2222 root@192.210.137.113 'hostname; tailscale ip -4; nginx -v; ss -ltnp | grep -E ":(80|443|2222)\\b"'`
112-- VPS upstream 探活:`ssh -p 2222 root@192.210.137.113 'curl http://100.71.210.78:4317/healthz && curl http://100.112.239.13:4317/healthz'`
113-- VPS status-api 探活:`ssh -p 2222 root@192.210.137.113 'curl http://100.71.210.78:4318/healthz && curl http://100.112.239.13:4318/healthz'`
114-- VPS 依赖:`ssh -p 2222 root@192.210.137.113 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2-utils python3-certbot-dns-cloudflare'`
115-- VPS Cloudflare DNS challenge 凭据:`printf 'dns_cloudflare_api_token = %s\n' "$CLOUDFLARE_API_TOKEN" | ssh -p 2222 root@192.210.137.113 'install -d -m 700 /root/.secrets/certbot && cat > /root/.secrets/certbot/cloudflare.ini && chmod 600 /root/.secrets/certbot/cloudflare.ini'`
116-- VPS 三张证书:三次 `ssh -p 2222 root@192.210.137.113 'certbot certonly --non-interactive --agree-tos --register-unsafely-without-email --preferred-challenges dns-01 --authenticator dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini --dns-cloudflare-propagation-seconds 30 --cert-name <host> -d <host>'`
117-- 本地直连域名密码文件:`/Users/george/.config/baa-conductor/direct-node-basic-auth.env`
118-- VPS htpasswd:`printf '%s\n' "$BAA_DIRECT_NODE_BASIC_AUTH_PASSWORD" | ssh -p 2222 root@192.210.137.113 "htpasswd -i -c /etc/nginx/.htpasswd-baa-conductor conductor-ops && chmod 640 /etc/nginx/.htpasswd-baa-conductor && chown root:www-data /etc/nginx/.htpasswd-baa-conductor"`
119-- Nginx bundle 同步:`rsync -az -e 'ssh -p 2222' /Users/george/code/baa-conductor-T028-v2/.tmp/ops/baa-conductor-nginx/ root@192.210.137.113:/tmp/baa-conductor-nginx/`
120-- VPS 安装 / 校验 / reload:`ssh -p 2222 root@192.210.137.113 'cd /tmp/baa-conductor-nginx && ./deploy-on-vps.sh'`、`ssh -p 2222 root@192.210.137.113 'cd /tmp/baa-conductor-nginx && ./deploy-on-vps.sh --reload'`
121-- Cloudflare DNS 初次切换:`bash /Users/george/code/baa-conductor-T028-v2/.tmp/ops/cloudflare-dns-preview.sh`
122-- Cloudflare DNS 改为 DNS-only:`scripts/ops/cloudflare-dns-plan.sh --env /Users/george/.config/baa-conductor/ops.env --fetch-current --emit-shell .tmp/ops/cloudflare-dns-proxy-off.sh --output .tmp/ops/cloudflare-dns-plan-proxy-off.json`、`bash /Users/george/code/baa-conductor-T028-v2/.tmp/ops/cloudflare-dns-proxy-off.sh`
123-- 验收:`curl -H "Authorization: Bearer $CONTROL_API_OPS_ADMIN_TOKEN" https://control-api.makefile.so/v1/system/state`、`curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/healthz`、`curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/rolez`、`curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/rolez`、`curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/rolez`
124-- 收尾校验:`bash -n scripts/ops/*.sh scripts/runtime/*.sh`、`git diff --check`
125-- 提交与推送:`git commit -m "Complete T-028 real rollout"`、`git push -u origin feat/T-028-real-rollout-v2`
126-
127-## result
128-
129-- `mini` 与 `mac` 已从旧的 loopback-only 配置切到各自的 Tailscale `100.x` 监听:`mini 100.71.210.78:4317/4318`、`mac 100.112.239.13:4317/4318`,两台节点都通过了真实 `check-node.sh --check-loaded`。
130-- VPS `racknerd-ff37952` 已真实打通到两台节点的 `4317` 与 `4318`;`curl http://100.71.210.78:4317/healthz`、`curl http://100.112.239.13:4317/healthz` 以及对应 `4318` 探针都返回 `ok`。
131-- VPS 已安装 `apache2-utils` 与 `python3-certbot-dns-cloudflare`,并通过 Cloudflare DNS challenge 签发三张证书:`conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so`,到期日均为 `2026-06-19`。
132-- `/etc/nginx/.htpasswd-baa-conductor` 已创建,仓库生成的 Nginx bundle 已同步到 VPS,`./deploy-on-vps.sh` 与 `./deploy-on-vps.sh --reload` 都真实通过,`nginx -t` 成功。
133-- Cloudflare DNS 已真实切换。三条 conductor 记录最终状态为 `A -> 192.210.137.113 proxied=false`;Cloudflare API 回读计划为 `noop`,权威 NS `giancarlo.ns.cloudflare.com` 对三个 host 都返回 `192.210.137.113`。
134-- 公网验收闭环完成:`https://control-api.makefile.so/v1/system/state` 返回 `ok=true` 且 `holder_id=mini-main`;`conductor.makefile.so` 入口返回 `healthz=ok`、`rolez=leader`;`mini-conductor.makefile.so` / `mac-conductor.makefile.so` 未认证为 `401`,带 Basic Auth 后分别返回 `leader` / `standby`。
135-- 为了让 Tailscale rollout 的静态校验真实可用,本分支顺手修复了 `scripts/runtime/check-node.sh`,让它把 `--local-api-allowed-hosts` 和 `--status-api-host` 正确传给 `check-launchd.sh`。
136-- 分支已推送:`origin/feat/T-028-real-rollout-v2`
137-
138-## risks
139-
140-- 这三条 conductor DNS 记录当前是 `proxied=false`。原因不是源站问题,而是当前 Cloudflare token 只有 DNS 权限,无法把 zone SSL mode 从疑似 `Flexible` 改到 `Full` / `Full (strict)`;若重新开代理,公网会回到 `301 Location: https://$host$request_uri` 循环。
141-- `mini` 与 `mac` 运行时现在都指向 `/Users/george/code/baa-conductor-T028-v2`,不是文档默认的 `/Users/george/code/baa-conductor`;如果后续切回 canonical 路径,需要重新渲染并 reload launchd 副本。
142-- `mini/mac` 直连域名的 Basic Auth 密码只保存在仓库外的 `/Users/george/.config/baa-conductor/direct-node-basic-auth.env` 和 VPS htpasswd 文件里,没有进入 repo;交接时需要确保整合者知道这份私有文件的位置。
143-
144-## next_handoff
145-
146-- 如需把 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 重新放回 Cloudflare 代理,先准备带 zone settings 权限的 token,把 `makefile.so` 的 SSL mode 调到 `Full` 或 `Full (strict)`,再把私有 inventory `/Users/george/.config/baa-conductor/ops.env` 里的 `BAA_CF_PROXY_*` 改回 `true` 并重新执行 DNS 计划脚本。
147-- 观察三张证书的首次自动续期是否正常;若要显式演练,可在 VPS 上执行 `certbot renew --dry-run`。
148-- 后续若进入 `T-029`,就直接基于当前 `mini leader / mac standby / VPS ingress` 现网做 smoke、切换与长时间稳定性回归,不需要再重新铺节点监听或公网入口。
149-
150-开始时建议直接把 `status` 改为 `in_progress`。
151-
152-做完并推送后:
153-
154-- 如果等待整合,改为 `review`
155-- 如果确认结束,改为 `done`
D coordination/tasks/done/T-028-unblock-tailscale-listen.md
+0, -112
  1@@ -1,112 +0,0 @@
  2----
  3-task_id: T-028A
  4-title: T-028 Tailscale 监听解阻
  5-status: done
  6-branch: feat/T-028-unblock-tailscale-listen
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@0b59439
  9-depends_on:
 10-  - T-028
 11-write_scope:
 12-  - apps/conductor-daemon/**
 13-  - apps/status-api/**
 14-  - docs/runtime/**
 15-  - docs/ops/**
 16-  - scripts/runtime/**
 17-updated_at: 2026-03-22T03:02:38+0800
 18----
 19-
 20-# T-028 Tailscale 监听解阻
 21-
 22-## 目标
 23-
 24-解除 T-028 的当前真实阻塞:允许 VPS 通过 Tailscale `100.x` 回源到 `mini` / `mac` 的 conductor 与 status-api 监听面,同时保持默认 loopback 行为安全。
 25-
 26-## 本任务包含
 27-
 28-- 为 `conductor-daemon` 增加受控的 Tailscale `100.x` 监听支持
 29-- 为 `status-api` 补齐 launchd/runtime 层的显式 host 配置支持
 30-- 更新 runtime / ops 文档,写清节点监听与 VPS 回源约定
 31-- 运行与本次实现直接相关的本地验证
 32-
 33-## 本任务不包含
 34-
 35-- 修改 `baa-firefox`
 36-- 真实 DNS 切换
 37-- 直接改 VPS 线上 Nginx 配置
 38-- 与监听解阻无关的重构
 39-
 40-## 建议起始文件
 41-
 42-- `apps/conductor-daemon/src/index.ts`
 43-- `apps/conductor-daemon/src/index.test.js`
 44-- `apps/status-api/src/host.ts`
 45-- `scripts/runtime/install-launchd.sh`
 46-- `scripts/runtime/check-launchd.sh`
 47-- `docs/runtime/environment.md`
 48-- `docs/ops/README.md`
 49-
 50-## 交付物
 51-
 52-- 可显式配置为 Tailscale `100.x` 监听的 conductor / status-api
 53-- 更新后的 launchd/runtime 文档与脚本支持
 54-- 记录了验证命令、结果和后续交接项的任务卡
 55-
 56-## 验收
 57-
 58-- `BAA_CONDUCTOR_LOCAL_API` 不再被硬性限制为 loopback,且只接受 loopback 或显式允许的 Tailscale `100.x`
 59-- `status-api` 默认仍监听 `127.0.0.1`,但能通过配置监听节点 Tailscale `100.x`
 60-- runtime / ops 文档说明 mini / mac 配置与 VPS 回源地址
 61-- `typecheck`
 62-- `build`
 63-- conductor-daemon 相关测试
 64-- status-api 最小 smoke 或启动验证
 65-- `git diff --check`
 66-
 67-## files_changed
 68-
 69-- `apps/conductor-daemon/src/index.ts`
 70-- `apps/conductor-daemon/src/index.test.js`
 71-- `scripts/runtime/install-launchd.sh`
 72-- `scripts/runtime/check-launchd.sh`
 73-- `ops/launchd/so.makefile.baa-status-api.plist`
 74-- `docs/runtime/environment.md`
 75-- `docs/runtime/launchd.md`
 76-- `docs/runtime/node-verification.md`
 77-- `docs/ops/README.md`
 78-- `docs/ops/failover-topology.md`
 79-- `coordination/tasks/T-028-unblock-tailscale-listen.md`
 80-
 81-## commands_run
 82-
 83-- `git worktree add -b feat/T-028-unblock-tailscale-listen /Users/george/code/baa-conductor-T028-unblock-tailscale-listen 0b59439`
 84-- `npx --yes pnpm install`
 85-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
 86-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
 87-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon test`
 88-- `npx --yes pnpm typecheck`
 89-- `npx --yes pnpm build`
 90-- `node apps/status-api/dist/index.js smoke`
 91-- `bash -n scripts/runtime/common.sh scripts/runtime/install-launchd.sh scripts/runtime/check-launchd.sh scripts/runtime/check-node.sh`
 92-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
 93-- `git diff --check`
 94-- `git push -u origin feat/T-028-unblock-tailscale-listen`
 95-
 96-## result
 97-
 98-- `conductor-daemon` 现在允许 `BAA_CONDUCTOR_LOCAL_API` 绑定 loopback 或显式列入 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` 的 Tailscale `100.x`,不再硬性报错为 loopback-only。
 99-- `status-api` 应用代码本身已支持 `BAA_STATUS_API_HOST`;本分支补齐了 launchd 模板、安装脚本和校验脚本,使真实节点可以稳定写入 `100.71.210.78` / `100.112.239.13`。
100-- runtime / ops 文档已明确 mini / mac 的监听覆盖值,以及 VPS 继续回源 `100.71.210.78:4317` 主、`100.112.239.13:4317` 备的约定。
101-- 本地验证通过:仓库级 `typecheck`、仓库级 `build`、`conductor-daemon` 测试、`status-api` smoke、shell 语法检查、plist lint、`git diff --check`。
102-- 分支已推送:`origin/feat/T-028-unblock-tailscale-listen`
103-
104-## risks
105-
106-- 当前实现是“单 host 显式绑定”,不是 loopback 与 Tailscale 双监听;节点如果切到 `100.x`,本机探针与 `check-node.sh` 也必须同步改用 Tailscale URL。
107-- 本分支没有执行真实 mini / mac launchd 重装,也没有做 VPS/Nginx 线上切换;T-028 后续仍需在真实节点套用文档中的新参数完成落地验证。
108-
109-## next_handoff
110-
111-- 在 `mini` 上重装或更新 launchd:`--all-services --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`。
112-- 在 `mac` 上重装或更新 launchd:`--all-services --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`。
113-- 两台节点分别执行 `check-launchd.sh` / `check-node.sh` 的 Tailscale 版本命令,确认 `4317` 与 `4318` 已在各自 `100.x` 上可达,再继续 T-028 的 VPS 回源与公网切换准备。
D coordination/tasks/done/T-029-stability-regression.md
+0, -129
  1@@ -1,129 +0,0 @@
  2----
  3-task_id: T-029
  4-title: 多节点长时间稳定性回归
  5-status: done
  6-branch: feat/T-029-stability-regression
  7-repo: /Users/george/code/baa-conductor
  8-base_ref: main@926860a
  9-depends_on:
 10-  - T-028
 11-write_scope:
 12-  - docs/ops/**
 13-  - docs/runtime/**
 14-  - tests/e2e/**
 15-  - scripts/smoke/**
 16-  - scripts/failover/**
 17-  - scripts/runtime/**
 18-updated_at: 2026-03-22 05:35:00 CST
 19----
 20-
 21-# T-029 多节点长时间稳定性回归
 22-
 23-## 目标
 24-
 25-在真实 mini / mac / VPS / Cloudflare 环境上完成一轮长时间稳定性回归,确认主备、探活、控制面和 smoke 脚本在真实环境下可持续工作。
 26-
 27-## 本任务包含
 28-
 29-- 基于 `T-028` 已上线环境跑多轮 smoke / failover / 恢复
 30-- 记录长时间运行期间的 `healthz`、`readyz`、`rolez`、`/v1/system/state`、`/v1/status`
 31-- 至少覆盖一次 planned failover 和一次 switchback
 32-- 把异常、抖动、人工干预点和最终结论写入回归报告
 33-
 34-## 本任务不包含
 35-
 36-- 重新部署 Cloudflare / VPS
 37-- 重写 control-api / conductor / status-api 代码
 38-- 修改 `baa-firefox` 仓库
 39-
 40-## 建议起始文件
 41-
 42-- `tests/e2e/smoke.test.mjs`
 43-- `scripts/smoke/run-e2e.sh`
 44-- `scripts/failover/rehearsal-check.sh`
 45-- `scripts/runtime/check-node.sh`
 46-- `docs/ops/README.md`
 47-
 48-## 交付物
 49-
 50-- 一份真实环境稳定性回归记录
 51-- 一份 failover / switchback 实测结果
 52-- 必要时更新后的 smoke / failover / on-node 文档
 53-
 54-## 验收
 55-
 56-- 能给出至少一轮真实环境 smoke 结果
 57-- 能给出至少一次 failover 和一次 switchback 的结果
 58-- `git diff --check`
 59-
 60-## 更新要求
 61-
 62-完成时更新 frontmatter 的:
 63-
 64-- `status`
 65-- `base_ref`
 66-- `updated_at`
 67-
 68-并补充下面这些内容:
 69-
 70-## files_changed
 71-
 72-- coordination/tasks/T-029-stability-regression.md
 73-- docs/ops/README.md
 74-- docs/ops/real-stability-regression-2026-03-22.md
 75-- scripts/smoke/README.md
 76-- scripts/smoke/live-regression.mjs
 77-
 78-## commands_run
 79-
 80-- `npx --yes pnpm install`
 81-- `./scripts/failover/print-topology.sh --env /Users/george/.config/baa-conductor/ops.env`
 82-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --expect-leader mini`
 83-- `node scripts/smoke/live-regression.mjs --env /Users/george/.config/baa-conductor/ops.env --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env --expect-leader mini`
 84-- `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
 85-- `ssh george@100.112.239.13 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded'`
 86-- `for i in 1 2 3 4 5 6 7; do node scripts/smoke/live-regression.mjs ... --expect-leader mini --compact; sleep 300; done`
 87-- `ps -axo pid=,command= | grep '/apps/worker-runner/dist/index.js' | grep -v grep || true`
 88-- `ssh george@100.112.239.13 "ps -axo pid=,command= | grep '/apps/worker-runner/dist/index.js' | grep -v grep || true"`
 89-- `curl -X POST ... https://control-api.makefile.so/v1/system/drain`
 90-- `curl -X POST ... https://control-api.makefile.so/v1/system/pause`
 91-- `launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"`
 92-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --skip-node mini --expect-leader mac`
 93-- `curl -X POST ... https://control-api.makefile.so/v1/system/resume`
 94-- `ssh george@100.112.239.13 'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'`
 95-- `cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents`
 96-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --skip-node mac --expect-leader mini`
 97-- `ssh george@100.112.239.13 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents'`
 98-- `dig @giancarlo.ns.cloudflare.com +short conductor.makefile.so`
 99-- `echo | openssl s_client -servername conductor.makefile.so -connect 192.210.137.113:443 2>/dev/null | openssl x509 -noout -dates`
100-
101-## result
102-
103-- 新增 `scripts/smoke/live-regression.mjs`,把 control-api、公网入口、直连入口、status-api 和鉴权结果收成一份真实环境快照;`scripts/smoke/README.md` 与 `docs/ops/README.md` 已补用法与报告入口。
104-- 在 live `mini/mac/VPS/Cloudflare` 环境上完成了一轮基线 smoke、一轮 `30.3` 分钟持续观察、一次 planned failover 和一次 switchback,并把过程写入 `docs/ops/real-stability-regression-2026-03-22.md`。
105-- steady-state 结论:control-api、公网 conductor、mini/mac 直连 conductor、Basic Auth、mini/mac on-node `check-node.sh` 都可以工作;final steady-state 已回到 `mini-main` leader、`mac-standby` standby、`mode=running`。
106-- failover / switchback 都真实成功,但都观察到了短暂外部不一致窗口:停掉当前 leader 后,公网或恢复中的节点会先返回 `standby`,随后才稳定到新的 `leader`。
107-- 本轮 smoke 没有完全通过:两台节点的 `status-api /v1/status` 全程都停在 `source=empty`, `mode=paused`, `leaderId=null`,与 control-api 真相不一致。
108-
109-## risks
110-
111-- live `status-api` 目前不是可靠真相源;它不能用来判断自动化是否真的在 `running`,也不能确认当前 leader、queueDepth 或 activeRuns。
112-- mini/mac 两侧 launchd 安装副本仍然指向 `/Users/george/code/baa-conductor-T028-v2`,还没有整理到 canonical repo path `/Users/george/code/baa-conductor`。
113-- switchback 不是“停 mac、起 mini”就完全结束;要恢复 canonical `mini leader + mac standby`,还需要额外显式 reload `mac` conductor。
114-- authoritative DNS 仍是 DNS-only,Cloudflare proxy 没有重新开启;本轮没有尝试把 conductor hosts 切回橙云。
115-- 本机非权威 DNS / 代理层对 `conductor.makefile.so`、`mac-conductor.makefile.so` 的解析与权威结果不一致,直接做 DNS/TLS 诊断时必须区分 authoritative 结果和本地 resolver 结果。
116-- 在 planned failover 前,我有一次把 `GET /v1/system/state` 和 `POST /v1/system/pause` 并行发出,所以那次 `mode=paused` 读数不能单独拿来证明 `drain` 的独立效果;后续状态读取都改回串行。
117-
118-## next_handoff
119-
120-- 优先排查 live `status-api` 为什么持续返回 `source=empty`,并让 `/v1/status` 跟随 control-api 的真实 leader / mode / queue state。
121-- 规划一次 runtime canonicalization,把 mini/mac 的 launchd、logs、runs、worktrees 路径从 `/Users/george/code/baa-conductor-T028-v2` 收口到 `/Users/george/code/baa-conductor`。
122-- 如果要把 switchback 降为更可重复的运维流程,runbook 或脚本层需要显式补上“恢复 mac standby”的尾步骤。
123-- 如果后续要重新启用 Cloudflare proxy,先把 DNS / TLS / zone SSL 模式的真实控制面整理清楚,再做单独演练,不要在当前 DNS-only 基线之上直接切橙云。
124-
125-开始时建议直接把 `status` 改为 `in_progress`。
126-
127-做完并推送后:
128-
129-- 如果等待整合,改为 `review`
130-- 如果确认结束,改为 `done`
M docs/auth/README.md
+1, -1
1@@ -14,7 +14,7 @@
2 
3 | Role | 代表对象 | 推荐 Token | 明确允许 | 明确禁止 |
4 | --- | --- | --- | --- | --- |
5-| `controller` | leader/standby conductor 节点 | `service_hmac`,后续可升级为 `service_signed` | `controllers.heartbeat`、`leader.acquire`、`tasks.plan`、`tasks.claim` | `system.pause`、`system.resume`、`system.drain`、任何维护操作、任何 step 执行回写 |
6+| `controller` | 当前单节点 conductor 服务 | `service_hmac`,后续可升级为 `service_signed` | `controllers.heartbeat`、`leader.acquire`、`tasks.plan`、`tasks.claim` | `system.pause`、`system.resume`、`system.drain`、任何维护操作、任何 step 执行回写 |
7 | `worker` | 运行 step 的 Codex worker | `service_hmac`,后续可升级为 `service_signed` | `steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` | claim task、lease、task 创建、系统级操作、维护操作 |
8 | `browser_admin` | 可见 Claude `control` 会话 / 浏览器管理面板 | `browser_session` | `tasks.create`、`system.pause`、`system.resume`、`system.drain`、状态/日志查看 | lease、claim、step 执行回写、主备切换 |
9 | `ops_admin` | 运维维护入口 | `ops_session` | `maintenance.promote`、`maintenance.demote`、状态/日志查看 | task 创建、普通调度、step 执行回写、浏览器控制按钮 |
M docs/decisions/0001-single-node-mini.md
+10, -7
 1@@ -6,7 +6,7 @@
 2 
 3 ## 决策
 4 
 5-当前项目不再继续推进 `mini + mac` 主备切换方案,统一收口为:
 6+当前项目统一收口为:
 7 
 8 - `mini` 是唯一长期运行的 conductor 节点
 9 - `mini` 负责自启动、状态面、控制面和公网入口
10@@ -14,14 +14,14 @@
11 
12 ## 原因
13 
14-- 当前项目后续会被其他项目替代,不值得继续为主备切换维护额外复杂度
15-- 现网主要剩余问题已经集中在 `mini` 自身的运行态收口,而不是多节点编排能力
16-- 主备切换文档、脚本和操作链条过长,继续维护的性价比低
17+- 当前项目后续会被其他项目替代,不值得继续维护主备复杂度
18+- 现网剩余问题已经集中在 `mini` 自身的运行态收口
19+- 切换文档、脚本和操作链条过长,继续维护性价比低
20 
21 ## 直接影响
22 
23 - 当前推荐运维模式改成 `mini` 单节点
24-- `mac` 相关的主备/failover/switchback 文档降级为历史参考
25+- 历史主备/failover/switchback 内容直接从主线移除
26 - 后续零散修复优先聚焦:
27   - live `status-api` 漂移
28   - `mini` runtime 路径 canonicalization
29@@ -29,6 +29,9 @@
30 
31 ## 不做的事情
32 
33-- 不在这一轮强拆所有历史 failover 代码路径
34-- 不把所有旧文档全部重写成单节点版本
35 - 不再围绕 `mac` 设计新的 rehearsal、切换或兜底流程
36+- 不为历史兼容保留多节点运维入口
37+
38+## 回溯方式
39+
40+- 旧的主备资料统一通过 tag `ha-failover-archive-2026-03-22` 回溯
M docs/decisions/README.md
+1, -9
 1@@ -1,14 +1,6 @@
 2 # decisions
 3 
 4-这个目录用于后续放 ADR 或设计决策记录。
 5-
 6-建议命名:
 7-
 8-- `0001-control-api-auth.md`
 9-- `0002-failover-policy.md`
10-- `0003-checkpoint-storage.md`
11-
12-当前阶段先保留目录与约定,不写具体决策内容。
13+这个目录用于记录当前仍然有效的设计决策。
14 
15 当前已经落地的决策:
16 
M docs/firefox/README.md
+1, -1
1@@ -67,7 +67,7 @@ Firefox 插件至少要读取这些字段:
2 | `automation.requested_by` | string \| null | 最近一次变更来源,建议用于审计展示 |
3 | `automation.reason` | string \| null | 最近一次变更原因 |
4 | `leader.controller_id` | string \| null | 当前 leader controller id |
5-| `leader.host` | string \| null | 当前 leader host,例如 `mini` 或 `mac` |
6+| `leader.host` | string \| null | 当前持有租约的 host;当前主线一般是 `mini` |
7 | `leader.role` | string \| null | 当前 leader 注册角色 |
8 | `leader.lease_expires_at` | integer \| null | 当前 lease 到期时间,毫秒时间戳 |
9 | `queue.active_runs` | integer | 当前运行中的 run 数 |
M docs/ops/README.md
+33, -187
  1@@ -1,79 +1,34 @@
  2 # VPS、Nginx 与 Cloudflare DNS 运维
  3 
  4-本目录收口公网入口的运维步骤。
  5+当前运维模型只保留一条路径:
  6 
  7-## 当前推荐模式
  8+- `control-api.makefile.so` 由 Cloudflare Worker / D1 提供
  9+- `conductor.makefile.so` 由 VPS Nginx 回源到 `mini`
 10+- `mini` 内网固定地址:`100.71.210.78:4317`
 11 
 12-从现在开始,当前有效运维目标改成:
 13+主备切换、直连 `mac` 的公网域名和历史切换 runbook 已从当前主线移除。
 14 
 15-- 只保留 `mini` 作为唯一中控
 16-- 只保留 `mini` 的自启动、探活、控制面与公网入口
 17-- 不再继续推进 `mac` 备主、planned failover、emergency failover、switchback
 18+## 当前 inventory
 19 
 20-下面的主备/failover 文档保留为历史参考,不再作为当前执行目标。
 21-
 22-当前方案固定遵守这几个约束:
 23-
 24-- 公网只暴露 VPS 的 `80/tcp` 与 `443/tcp`
 25-- `mini` 回源固定走 Tailscale `100.x` 地址
 26-- 不依赖 `*.ts.net` 的 MagicDNS 名称
 27-- 仓库脚本默认只做 DNS 计划、Nginx 渲染和部署分发,不会直接改线上 DNS
 28-- `deploy-on-vps.sh` 只有显式传 `--reload` 时才会重载 Nginx
 29-
 30-`control-api.makefile.so` 的 Worker 自定义域仍由 Cloudflare Worker / D1 相关任务管理,不在这里的脚本覆盖范围内。
 31-
 32-## 历史参考
 33-
 34-下面这些主备切换文档只保留作历史记录:
 35-
 36-- [`failover-topology.md`](./failover-topology.md)
 37-- [`planned-failover.md`](./planned-failover.md)
 38-- [`emergency-failover.md`](./emergency-failover.md)
 39-- [`switchback.md`](./switchback.md)
 40-
 41-配套只读/半自动脚本在:
 42-
 43-- [`../../scripts/failover/print-topology.sh`](../../scripts/failover/print-topology.sh)
 44-- [`../../scripts/failover/rehearsal-check.sh`](../../scripts/failover/rehearsal-check.sh)
 45-- [`../../scripts/failover/print-checklist.sh`](../../scripts/failover/print-checklist.sh)
 46-
 47-最新真实回归记录仍保留在:
 48-
 49-- [`real-stability-regression-2026-03-22.md`](./real-stability-regression-2026-03-22.md)
 50-
 51-## 单一来源 inventory
 52-
 53-本任务把公网域名、VPS 公网 IP、内网 Tailscale `100.x` 和 Nginx 安装路径收口到一份 inventory:
 54+使用一份最小 inventory:
 55 
 56 - [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
 57-- [`scripts/ops/cloudflare-dns-plan.sh`](../../scripts/ops/cloudflare-dns-plan.sh)
 58-- [`scripts/ops/nginx-sync-plan.sh`](../../scripts/ops/nginx-sync-plan.sh)
 59-- [`ops/nginx/templates/baa-conductor.conf.template`](../../ops/nginx/templates/baa-conductor.conf.template)
 60-- [`ops/nginx/templates/includes/direct-node-auth.conf.template`](../../ops/nginx/templates/includes/direct-node-auth.conf.template)
 61-
 62-推荐做法:
 63-
 64-1. 把 `baa-conductor.env.example` 复制到仓库外的私有路径
 65-2. 在那份私有 inventory 里填写真实 VPS 公网 IP、Cloudflare Zone ID 和证书路径
 66-3. 用同一份 inventory 先出 DNS 计划,再渲染 Nginx bundle
 67-
 68-## 当前域名与内网关系
 69 
 70-| 公网域名 | Cloudflare DNS 目标 | VPS 上的 Nginx upstream | 内网实际回源 |
 71-| --- | --- | --- | --- |
 72-| `conductor.makefile.so` | VPS 公网 IP | `conductor_primary` | `mini 100.71.210.78:4317` |
 73-| `mini-conductor.makefile.so` | VPS 公网 IP | `mini_conductor_direct` | `100.71.210.78:4317` |
 74+里面只需要维护:
 75 
 76-统一原则:
 77+- Cloudflare zone
 78+- VPS 公网 IP
 79+- `conductor.makefile.so`
 80+- `mini` 的 Tailscale `100.x`
 81+- 证书与 Nginx 安装路径
 82 
 83-- 当前优先维护 `conductor.makefile.so` 和 `mini-conductor.makefile.so`
 84-- 只有 VPS 对公网暴露 `80/443`
 85-- `4317` 只允许 VPS 通过 Tailscale 或 WireGuard 访问
 86-- `mini-conductor.makefile.so` 默认保留 Basic Auth
 87+## 当前公网关系
 88 
 89-## 节点监听配置
 90+| 公网域名 | 目标 | 回源 |
 91+| --- | --- | --- |
 92+| `conductor.makefile.so` | VPS 公网 IP | `100.71.210.78:4317` |
 93 
 94-要让 VPS 真实回源成功,节点本地 runtime 需要明确切到自己的 Tailscale `100.x`,而不是继续停在 loopback 默认值。
 95+## 当前节点监听
 96 
 97 `mini`:
 98 
 99@@ -83,15 +38,9 @@ BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78
100 BAA_STATUS_API_HOST=100.71.210.78
101 ```
102 
103-运维边界:
104+## DNS 计划
105 
106-- VPS Nginx 继续只回源 `mini` 的 conductor,也就是 `100.71.210.78:4317`
107-- `status-api` 如需 Tailscale 直连,地址对应 `http://100.71.210.78:4318`
108-- 当前实现不是双监听;切到 `100.x` 后,节点本地检查要同步改用 `check-node.sh --local-api-base ... --status-api-base ...`
109-
110-## Cloudflare DNS 计划
111-
112-### 1. 准备 inventory
113+### 1. 准备私有 inventory
114 
115 ```bash
116 cp scripts/ops/baa-conductor.env.example ../baa-conductor.ops.env
117@@ -99,20 +48,13 @@ $EDITOR ../baa-conductor.ops.env
118 export CLOUDFLARE_API_TOKEN=...your token...
119 ```
120 
121-注意:
122-
123-- 真实 inventory 建议放仓库外,避免把公网 IP、Zone ID 或 token 变量名混进提交
124-- token 不写进文件,脚本从 `BAA_CF_API_TOKEN_ENV` 指定的环境变量读取
125-
126 ### 2. 只看目标记录
127 
128 ```bash
129 scripts/ops/cloudflare-dns-plan.sh --env ../baa-conductor.ops.env
130 ```
131 
132-这一步只渲染期望记录,不访问 Cloudflare API。
133-
134-### 3. 读取现网并生成变更预览
135+### 3. 生成变更预览
136 
137 ```bash
138 scripts/ops/cloudflare-dns-plan.sh \
139@@ -122,25 +64,11 @@ scripts/ops/cloudflare-dns-plan.sh \
140   --output .tmp/ops/cloudflare-dns-plan.json
141 ```
142 
143-这一步会:
144-
145-- 用 Cloudflare DNS GET API 读取当前记录
146-- 输出 create / update / delete 计划
147-- 生成一个 `curl` 预览脚本,供人工审阅后再决定是否执行
148-
149-安全边界:
150-
151-- 脚本本身不会发 `POST` / `PATCH` / `DELETE`
152-- `--emit-shell` 只是把预览命令写到文件里,不会自动执行
153+脚本只生成计划,不会直接改线上 DNS。
154 
155-实战注意:
156+## Nginx 渲染与部署
157 
158-- 如果 `conductor*.makefile.so` 已经有有效源站证书、`--resolve` 直打 VPS 的 `https://.../healthz` 正常,但经 Cloudflare 代理访问时返回 `301 Location: https://$host$request_uri` 自重定向,通常说明 zone 仍在 `Flexible`。
159-- 当前 token 如果没有 zone settings 权限,无法直接把 SSL mode 改到 `Full` / `Full (strict)`;这种情况下先把 `BAA_CF_PROXY_*` 设成 `false`,切成 DNS-only,避免公网入口卡在 Cloudflare 边缘循环。
160-
161-## Nginx 渲染与部署 bundle
162-
163-### 1. 渲染并打包
164+### 1. 渲染 bundle
165 
166 ```bash
167 scripts/ops/nginx-sync-plan.sh \
168@@ -148,16 +76,7 @@ scripts/ops/nginx-sync-plan.sh \
169   --bundle-dir .tmp/ops/baa-conductor-nginx
170 ```
171 
172-bundle 内会包含:
173-
174-- 渲染后的 `etc/nginx/sites-available/baa-conductor.conf`
175-- 渲染后的 `etc/nginx/includes/baa-conductor/direct-node-auth.conf`
176-- 当前的 `common-proxy.conf`
177-- `inventory-summary.json`
178-- `DEPLOY_COMMANDS.txt`
179-- `deploy-on-vps.sh`
180-
181-### 2. 校验模板是否仍与仓库默认配置一致
182+### 2. 校验仓库默认配置
183 
184 ```bash
185 scripts/ops/nginx-sync-plan.sh \
186@@ -165,8 +84,6 @@ scripts/ops/nginx-sync-plan.sh \
187   --check-repo
188 ```
189 
190-这一步用于维护仓库里的默认模板和默认配置,不是线上部署必需步骤。
191-
192 ### 3. 分发到 VPS
193 
194 ```bash
195@@ -175,87 +92,16 @@ ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh'
196 ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
197 ```
198 
199-行为说明:
200-
201-- 第一个 `deploy-on-vps.sh` 会安装文件并执行 `nginx -t`,但不会 reload
202-- 只有显式传 `--reload` 时才会执行 `systemctl reload nginx`
203-
204 ## 前置条件
205 
206-部署前确认:
207-
208 1. VPS 已安装 `nginx`
209-2. VPS 能访问:
210-   - `100.71.210.78:4317`
211-   - `100.112.239.13:4317`
212-3. 三个公网域名都已准备好 DNS 记录
213-4. 证书路径已经与 inventory 一致
214-5. 若启用 Basic Auth,VPS 已安装 `htpasswd`
215-
216-常见依赖:
217-
218-```bash
219-sudo apt-get update
220-sudo apt-get install -y nginx apache2-utils certbot
221-```
222-
223-若证书通过 Cloudflare DNS challenge 或其他流程签发,按现有方式安装对应插件。
224-
225-## Basic Auth 与证书
226-
227-直连域名默认引用:
228-
229-- `auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor`
230-- `mini-conductor.makefile.so`
231-- `mac-conductor.makefile.so`
232-
233-准备 Basic Auth:
234-
235-```bash
236-sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
237-```
238-
239-准备证书时,仓库默认按 Let’s Encrypt 路径渲染:
240-
241-- `/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem`
242-- `/etc/letsencrypt/live/conductor.makefile.so/privkey.pem`
243-- `/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem`
244-- `/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem`
245-- `/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem`
246-- `/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem`
247-
248-若改用 Cloudflare Origin Cert,就把 inventory 里的证书根目录改成实际落盘位置,然后重新生成 bundle。
249-
250-## 上线后验证
251-
252-入口与跳转:
253-
254-```bash
255-curl -I http://conductor.makefile.so
256-curl -I https://conductor.makefile.so/healthz
257-```
258-
259-预期:
260-
261-- `http://conductor.makefile.so` 返回 `301`
262-- `https://conductor.makefile.so/healthz` 返回 `200`
263-
264-直连 mini/mac:
265-
266-```bash
267-curl -I https://mini-conductor.makefile.so/healthz
268-curl -u conductor-ops:YOUR_PASSWORD https://mini-conductor.makefile.so/healthz
269-curl -u conductor-ops:YOUR_PASSWORD https://mac-conductor.makefile.so/healthz
270-```
271-
272-预期:
273-
274-- 未带认证访问直连域名返回 `401`
275-- 带正确认证后返回 `200`
276+2. VPS 能访问 `100.71.210.78:4317`
277+3. `conductor.makefile.so` 已有 DNS 记录
278+4. 证书路径与 inventory 一致
279 
280-## 运维加固
281+## 说明
282 
283-- 主机防火墙明确拒绝公网访问 `4317`
284-- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 至少保留 Basic Auth
285-- 有固定办公出口时,在 [`ops/nginx/includes/direct-node-auth.conf`](../../ops/nginx/includes/direct-node-auth.conf) 基础上叠加 allowlist
286-- 域名走 Cloudflare 代理时,先恢复真实客户端 IP,再启用 `allow/deny`
287+- 当前只维护 `conductor.makefile.so`
288+- 不依赖 MagicDNS
289+- 是否启用 Cloudflare proxy 由实际证书和 SSL mode 决定
290+- 历史多节点资料只通过 tag `ha-failover-archive-2026-03-22` 回溯
D docs/ops/emergency-failover.md
+0, -145
  1@@ -1,145 +0,0 @@
  2-# Emergency Failover Runbook
  3-
  4-适用场景:
  5-
  6-- `mini` 宕机、不可登录、或其 conductor 行为已经不可信
  7-- 需要尽快把公网入口稳定在 `mac`
  8-
  9-这个 runbook 的目标是“先恢复可用性,再谈回切整洁度”。
 10-
 11-## 1. 判断当前属于哪种故障
 12-
 13-先拿一份只读快照:
 14-
 15-```bash
 16-./scripts/failover/rehearsal-check.sh \
 17-  --env ../baa-conductor.ops.env \
 18-  --basic-auth 'conductor-ops:REPLACE_ME' \
 19-  --bearer-token 'REPLACE_ME' \
 20-  --skip-node mini \
 21-  --expect-leader mac
 22-```
 23-
 24-根据结果分三类看:
 25-
 26-1. `mac` 直连已经是 `leader`,公网也正常
 27-   这说明 VPS 已经自动从 mini 掉到了 mac,只需要继续观察和记录。
 28-2. `mac` 直连不健康
 29-   这说明备用节点自己还没准备好,先修 `mac`。
 30-3. `mac` 已经是 `leader`,但公网入口仍不稳定,或者公网 `/rolez` 不是 `leader`
 31-   这通常说明 VPS 仍在命中 mini,或者 VPS 到 mac 的回源有问题。
 32-
 33-## 2. 先把 mac 修到可服务
 34-
 35-在 `mac` 上检查 launchd:
 36-
 37-```bash
 38-cd /Users/george/code/baa-conductor
 39-./scripts/runtime/check-launchd.sh \
 40-  --repo-dir /Users/george/code/baa-conductor \
 41-  --node mac \
 42-  --install-dir "$HOME/Library/LaunchAgents"
 43-```
 44-
 45-必要时重载:
 46-
 47-```bash
 48-cd /Users/george/code/baa-conductor
 49-./scripts/runtime/reload-launchd.sh
 50-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
 51-```
 52-
 53-如果安装副本不存在或与当前 repo 偏离,先补一次:
 54-
 55-```bash
 56-cd /Users/george/code/baa-conductor
 57-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
 58-./scripts/runtime/reload-launchd.sh
 59-```
 60-
 61-## 3. 检查 VPS 到 mac 的回源
 62-
 63-在 VPS 上确认:
 64-
 65-- `100.112.239.13:4317` 可达
 66-- Nginx 没有语法错误
 67-- TLS 证书和 include 仍然完整
 68-
 69-最小检查:
 70-
 71-```bash
 72-curl -sS http://100.112.239.13:4317/healthz
 73-curl -sS http://100.112.239.13:4317/rolez
 74-sudo nginx -t
 75-```
 76-
 77-如果这些都正常,但公网还是不落到 mac,说明 mini 侧可能仍有一个“能响应但不该接流量”的进程。
 78-
 79-## 4. 应急 Nginx 热修
 80-
 81-当前 canonical 配置是“mini 主、mac 备”。这是正常态最简配置,但应急时有一个代价:
 82-
 83-- 只要 mini 还能回 HTTP,公网就会先打到 mini
 84-- 即使 mini `/rolez` 已经不是 leader,Nginx 也不会主动让路
 85-
 86-这时可以在 VPS 上对已部署配置做临时热修,把 mac 调到前面。
 87-
 88-先备份:
 89-
 90-```bash
 91-sudo cp \
 92-  /etc/nginx/sites-available/baa-conductor.conf \
 93-  /etc/nginx/sites-available/baa-conductor.conf.bak.$(date +%Y%m%d%H%M%S)
 94-```
 95-
 96-临时把 `conductor_primary` 改成:
 97-
 98-```nginx
 99-upstream conductor_primary {
100-    server 100.112.239.13:4317 max_fails=2 fail_timeout=5s;
101-    server 100.71.210.78:4317 backup;
102-    keepalive 32;
103-}
104-```
105-
106-然后:
107-
108-```bash
109-sudo nginx -t
110-sudo systemctl reload nginx
111-```
112-
113-这一步的性质必须记清楚:
114-
115-- 这是“已部署文件”的应急热修,不是 repo 变更
116-- switchback 时必须把 VPS 恢复到 repo 生成的 canonical bundle
117-
118-## 5. 验证 emergency failover 已落稳
119-
120-再次执行:
121-
122-```bash
123-./scripts/failover/rehearsal-check.sh \
124-  --env ../baa-conductor.ops.env \
125-  --basic-auth 'conductor-ops:REPLACE_ME' \
126-  --bearer-token 'REPLACE_ME' \
127-  --skip-node mini \
128-  --expect-leader mac
129-```
130-
131-成功条件:
132-
133-- `conductor.makefile.so` 返回 `leader`
134-- `mac-conductor.makefile.so` 返回 `leader`
135-- `GET /v1/system/state` 的 `holder_id` 以 `mac-` 开头
136-
137-## 6. 事后记录
138-
139-emergency failover 结束后,至少记录:
140-
141-- mini 是“完全不可达”还是“仍可达但角色错误”
142-- mac 是否需要人工 reload launchd 才恢复
143-- VPS 是否做了临时 Nginx 热修
144-- 哪个备份文件是本次热修前的 `baa-conductor.conf`
145-
146-这些信息会直接决定后续 switchback 的复杂度。
D docs/ops/failover-topology.md
+0, -158
  1@@ -1,158 +0,0 @@
  2-# Failover Topology
  3-
  4-本页把当前主备设计里最容易混淆的四层关系写死:
  5-
  6-1. Cloudflare DNS
  7-2. VPS 上的 Nginx
  8-3. 两台节点的 Tailscale `100.x`
  9-4. 节点本地的 `launchd`
 10-
 11-目标不是引入新的自动切换机制,而是把现有约定整理成可 rehearsal 的操作模型。
 12-
 13-## 1. 固定拓扑
 14-
 15-### 公网入口
 16-
 17-- `conductor.makefile.so`
 18-- `mini-conductor.makefile.so`
 19-- `mac-conductor.makefile.so`
 20-
 21-这三个 host 都固定解析到同一台 VPS 公网 IP。failover 不通过改 DNS 完成,Cloudflare 只是把公网流量送到 VPS。
 22-
 23-`control-api.makefile.so` 是单独的 Cloudflare Worker 自定义域,仍然作为控制面使用:
 24-
 25-- `GET /v1/system/state`
 26-- `POST /v1/system/drain`
 27-- `POST /v1/system/pause`
 28-- `POST /v1/system/resume`
 29-
 30-它不经过 VPS Nginx,因此可以在 conductor 主备切换时继续承担“冻结/恢复自动化”和“读取 lease 状态”的职责。
 31-
 32-### VPS Nginx
 33-
 34-仓库里的 canonical 配置见:
 35-
 36-- [`ops/nginx/baa-conductor.conf`](../../ops/nginx/baa-conductor.conf)
 37-
 38-当前 upstream 关系是:
 39-
 40-| 公网 host | VPS upstream | 实际回源 |
 41-| --- | --- | --- |
 42-| `conductor.makefile.so` | `conductor_primary` | `mini 100.71.210.78:4317` 主,`mac 100.112.239.13:4317` 备 |
 43-| `mini-conductor.makefile.so` | `mini_conductor_direct` | `mini 100.71.210.78:4317` |
 44-| `mac-conductor.makefile.so` | `mac_conductor_direct` | `mac 100.112.239.13:4317` |
 45-
 46-关键点:
 47-
 48-- `conductor.makefile.so` 只会在 mini 上游连不上时,才 TCP 级别回退到 mac。
 49-- Nginx 不看 lease,不知道谁是逻辑 leader。
 50-- 因此只要 mini 仍然在 `100.71.210.78:4317` 上返回 HTTP,公网流量就还会优先打到 mini。
 51-
 52-这意味着:
 53-
 54-- planned failover 不能只让 mac 获得 lease,还必须让 mini 的 conductor 停掉或不再对 VPS 可达。
 55-- emergency failover 时,如果 mini 还活着但已经不是 leader,可能需要临时热修 VPS 上的已部署 Nginx 配置。
 56-
 57-### Tailscale `100.x`
 58-
 59-当前仓库明确固定使用 Tailscale IPv4,不依赖 MagicDNS:
 60-
 61-- `mini`: `100.71.210.78`
 62-- `mac`: `100.112.239.13`
 63-- port: `4317`
 64-
 65-这些值来自 inventory:
 66-
 67-- [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
 68-
 69-### launchd
 70-
 71-节点进程是否存在、以什么身份存在,由 `launchd` 安装副本决定。
 72-
 73-默认节点身份见:
 74-
 75-- [`docs/runtime/environment.md`](../runtime/environment.md)
 76-
 77-| 节点 | `BAA_CONDUCTOR_HOST` | `BAA_CONDUCTOR_ROLE` | `BAA_NODE_ID` |
 78-| --- | --- | --- | --- |
 79-| `mini` | `mini` | `primary` | `mini-main` |
 80-| `mac` | `mac` | `standby` | `mac-standby` |
 81-
 82-真实回源还要把监听地址显式切到节点自己的 Tailscale `100.x`:
 83-
 84-- `mini`: `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317`
 85-- `mini`: `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78`
 86-- `mac`: `BAA_CONDUCTOR_LOCAL_API=http://100.112.239.13:4317`
 87-- `mac`: `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.112.239.13`
 88-
 89-安装/校验/重载脚本见:
 90-
 91-- [`scripts/runtime/install-launchd.sh`](../../scripts/runtime/install-launchd.sh)
 92-- [`scripts/runtime/check-launchd.sh`](../../scripts/runtime/check-launchd.sh)
 93-- [`scripts/runtime/reload-launchd.sh`](../../scripts/runtime/reload-launchd.sh)
 94-
 95-`reload-launchd.sh` 适合“把安装副本重新 bootstrap/kickstart”;planned failover 要“只停 conductor,不立刻重启”时,直接用 `launchctl bootout` 更合适。
 96-
 97-## 2. 当前 failover 语义
 98-
 99-当前设计的真实行为可以压缩成一句话:
100-
101-> 逻辑 leader 由 lease 决定,公网入口是否切走则由 VPS 是否还能连通当前 primary upstream 决定。
102-
103-所以三种场景的区别是:
104-
105-- planned failover: 先冻结自动化,再显式停掉 mini conductor,让 VPS 落到 mac backup,上游 lease 也随之迁到 mac。
106-- emergency failover: mini 已宕机或不可信,优先保证 mac 可服务;必要时在 VPS 上做临时 Nginx 热修。
107-- switchback: mini 修复后,先让 mini 恢复健康,再停掉 mac conductor,并把 VPS 配置恢复为 canonical 的 mini 主、mac 备。
108-
109-## 3. Rehearsal 辅助脚本
110-
111-新增脚本都在:
112-
113-- [`scripts/failover/common.sh`](../../scripts/failover/common.sh)
114-- [`scripts/failover/print-topology.sh`](../../scripts/failover/print-topology.sh)
115-- [`scripts/failover/rehearsal-check.sh`](../../scripts/failover/rehearsal-check.sh)
116-- [`scripts/failover/print-checklist.sh`](../../scripts/failover/print-checklist.sh)
117-
118-它们的边界是:
119-
120-- 只做只读 GET 检查,或输出 checklist
121-- 不会直接执行真实 failover
122-- 不会改 DNS
123-- 不会改 repo 里的 Nginx 模板
124-
125-推荐先看拓扑:
126-
127-```bash
128-./scripts/failover/print-topology.sh --env ../baa-conductor.ops.env
129-```
130-
131-再做一次基线探测:
132-
133-```bash
134-./scripts/failover/rehearsal-check.sh \
135-  --env ../baa-conductor.ops.env \
136-  --basic-auth 'conductor-ops:REPLACE_ME' \
137-  --bearer-token 'REPLACE_ME' \
138-  --expect-leader mini
139-```
140-
141-如果要拿到按场景整理好的命令骨架:
142-
143-```bash
144-./scripts/failover/print-checklist.sh \
145-  --scenario planned \
146-  --env ../baa-conductor.ops.env
147-```
148-
149-## 4. 使用这些 runbook 的前提
150-
151-开始任何 rehearsal 之前,至少准备好:
152-
153-- `mini`、`mac`、VPS 的 shell 访问权限
154-- 直连域名 Basic Auth 凭据
155-- `GET /v1/system/state` 的 readonly 或 browser_admin token
156-- `POST /v1/system/drain|pause|resume` 的 browser_admin token
157-- 一份仓库外的 inventory env 文件
158-
159-若这些前提不满足,本轮只能做“文档演练”或“单机只读检查”,不要冒险把真实主节点停掉。
D docs/ops/planned-failover.md
+0, -184
  1@@ -1,184 +0,0 @@
  2-# Planned Failover Runbook
  3-
  4-适用场景:
  5-
  6-- `mini` 当前是 leader
  7-- `mac` 已安装并加载 standby conductor
  8-- 计划内维护,需要把公网入口从 `mini` 平滑切到 `mac`
  9-
 10-非目标:
 11-
 12-- 不改 DNS
 13-- 不改 app 代码
 14-- 不让 `baa-firefox` 介入切换逻辑
 15-
 16-## 1. 前置条件
 17-
 18-准备这些材料:
 19-
 20-- 仓库外 inventory,例如 `../baa-conductor.ops.env`
 21-- 直连域名 Basic Auth
 22-- `GET /v1/system/state` 的 readonly token
 23-- `POST /v1/system/drain|pause|resume` 的 browser_admin token
 24-- `mini` / `mac` 的 shell 访问权限
 25-
 26-先确认当前拓扑和基线:
 27-
 28-```bash
 29-./scripts/failover/print-topology.sh --env ../baa-conductor.ops.env
 30-
 31-./scripts/failover/rehearsal-check.sh \
 32-  --env ../baa-conductor.ops.env \
 33-  --basic-auth 'conductor-ops:REPLACE_ME' \
 34-  --bearer-token 'REPLACE_ME' \
 35-  --expect-leader mini
 36-```
 37-
 38-预期:
 39-
 40-- `conductor.makefile.so` 的 `/rolez` 返回 `leader`
 41-- `mini-conductor.makefile.so` 的 `/rolez` 返回 `leader`
 42-- `mac-conductor.makefile.so` 的 `/rolez` 返回 `standby`
 43-- `GET /v1/system/state` 的 `holder_id` 以 `mini-` 开头
 44-
 45-## 2. 冻结自动化
 46-
 47-planned failover 先 drain,再 pause。
 48-
 49-`drain` 的目的:
 50-
 51-- 不再启动新的 work
 52-- 给当前运行中的 task 一个自然收尾窗口
 53-
 54-当前仓库没有“自动等到 active runs 归零”的单独脚本,所以这里需要人工观察 status 面板、任务板或日志,确认已经到可切换窗口。
 55-
 56-执行:
 57-
 58-```bash
 59-export CONTROL_API_BASE='https://control-api.makefile.so'
 60-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
 61-
 62-curl -sS -X POST \
 63-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 64-  -H 'Content-Type: application/json' \
 65-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_rehearsal"}' \
 66-  "${CONTROL_API_BASE%/}/v1/system/drain"
 67-
 68-curl -sS -X POST \
 69-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 70-  -H 'Content-Type: application/json' \
 71-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_cutover"}' \
 72-  "${CONTROL_API_BASE%/}/v1/system/pause"
 73-```
 74-
 75-## 3. 先确认 mac standby 可接手
 76-
 77-在 `mac` 上先做 launchd 静态/加载校验:
 78-
 79-```bash
 80-cd /Users/george/code/baa-conductor
 81-./scripts/runtime/check-launchd.sh \
 82-  --repo-dir /Users/george/code/baa-conductor \
 83-  --node mac \
 84-  --install-dir "$HOME/Library/LaunchAgents"
 85-
 86-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
 87-```
 88-
 89-如果安装副本有漂移,先重渲染再继续:
 90-
 91-```bash
 92-cd /Users/george/code/baa-conductor
 93-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
 94-./scripts/runtime/reload-launchd.sh
 95-```
 96-
 97-## 4. 切走 mini
 98-
 99-这是本设计里最关键的一步。
100-
101-原因:
102-
103-- `mac` 获得 lease 还不够
104-- 只要 mini 继续在 `100.71.210.78:4317` 上返回 HTTP,VPS Nginx 仍会优先把公网流量送到 mini
105-
106-所以 planned failover 要显式停掉 mini 的 conductor 进程,而不是只做逻辑层 promote/demote。
107-
108-默认 `LaunchAgents` 方式:
109-
110-```bash
111-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
112-```
113-
114-如果该节点使用 `LaunchDaemons`:
115-
116-```bash
117-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
118-```
119-
120-注意:
121-
122-- 这里只停 `so.makefile.baa-conductor`
123-- `worker-runner`、`status-api` 是否一起停,由维护窗口自己决定
124-- 本 runbook 默认只切 conductor,不扩大停机面
125-
126-## 5. 验证 cutover
127-
128-mini conductor 停掉后,再做一次探测:
129-
130-```bash
131-./scripts/failover/rehearsal-check.sh \
132-  --env ../baa-conductor.ops.env \
133-  --basic-auth 'conductor-ops:REPLACE_ME' \
134-  --bearer-token 'REPLACE_ME' \
135-  --skip-node mini \
136-  --expect-leader mac
137-```
138-
139-预期:
140-
141-- `conductor.makefile.so` 仍然 `healthz=ok`、`readyz=ready`、`rolez=leader`
142-- `mac-conductor.makefile.so` 的 `/rolez` 变成 `leader`
143-- `GET /v1/system/state` 的 `holder_id` 以 `mac-` 开头
144-
145-如果 `mac` 已经是 leader,但公网 `/rolez` 仍不对:
146-
147-- 优先检查 VPS 到 `100.112.239.13:4317` 的连通性
148-- 再检查 mini 是否实际上还在对 `100.71.210.78:4317` 提供服务
149-- planned failover 不应该通过改 DNS 修复
150-
151-## 6. 恢复自动化
152-
153-确认公网入口和 mac 直连都健康后,再 `resume`:
154-
155-```bash
156-curl -sS -X POST \
157-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
158-  -H 'Content-Type: application/json' \
159-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_complete"}' \
160-  "${CONTROL_API_BASE%/}/v1/system/resume"
161-```
162-
163-## 7. Abort / 回滚
164-
165-如果在验证窗口里发现 mac 无法稳定接手:
166-
167-1. 保持 automation `paused`
168-2. 在 `mini` 上重新启动 conductor:
169-
170-```bash
171-cd /Users/george/code/baa-conductor
172-./scripts/runtime/reload-launchd.sh
173-```
174-
175-3. 再次确认基线回到 `mini`:
176-
177-```bash
178-./scripts/failover/rehearsal-check.sh \
179-  --env ../baa-conductor.ops.env \
180-  --basic-auth 'conductor-ops:REPLACE_ME' \
181-  --bearer-token 'REPLACE_ME' \
182-  --expect-leader mini
183-```
184-
185-4. 确认恢复正常后,再决定是否 `resume`
D docs/ops/real-rollout-2026-03-22.md
+0, -101
  1@@ -1,101 +0,0 @@
  2-# 2026-03-22 Real Rollout Record
  3-
  4-## Snapshot
  5-
  6-| Surface | Target | Actual state |
  7-| --- | --- | --- |
  8-| `control-api.makefile.so` | Cloudflare Worker + D1 | Already deployed and verified |
  9-| `conductor.makefile.so` | VPS `192.210.137.113` -> `mini/mac 100.x:4317` | Live |
 10-| `mini-conductor.makefile.so` | VPS `192.210.137.113` -> `mini 100.71.210.78:4317` | Live with Basic Auth |
 11-| `mac-conductor.makefile.so` | VPS `192.210.137.113` -> `mac 100.112.239.13:4317` | Live with Basic Auth |
 12-
 13-## Cloudflare
 14-
 15-- zone: `makefile.so`
 16-- zone id: `f3507ab962df815d93e7ad3f1a390615`
 17-- public IPv4: `192.210.137.113`
 18-- Worker custom domain kept as-is: `https://control-api.makefile.so`
 19-
 20-DNS records created on 2026-03-22 CST:
 21-
 22-- `conductor.makefile.so` record id `535f46affdb393e30a24e5f9fb5b95c5`
 23-- `mini-conductor.makefile.so` record id `41a6d2fab5071ea6de09eacabd5ffca6`
 24-- `mac-conductor.makefile.so` record id `e7011abac5c5684e22a047f5e2ced5d7`
 25-
 26-Final record state:
 27-
 28-- `A conductor.makefile.so -> 192.210.137.113 proxied=false`
 29-- `A mini-conductor.makefile.so -> 192.210.137.113 proxied=false`
 30-- `A mac-conductor.makefile.so -> 192.210.137.113 proxied=false`
 31-
 32-Why DNS-only:
 33-
 34-- With `proxied=true`, `--resolve ...:443:192.210.137.113` direct-to-origin checks were healthy, but public requests through Cloudflare returned `301 Location: https://$host$request_uri`.
 35-- The token available to this rollout could edit DNS but could not read or change zone SSL settings; `GET /zones/<zone>/settings/ssl` returned `9109 Unauthorized`.
 36-- The observed behavior is consistent with the zone still being on `Flexible`, so the rollout switched these three conductor records to DNS-only instead of leaving public traffic on a redirect loop.
 37-
 38-Authoritative DNS confirmation after the final PATCH:
 39-
 40-- `@giancarlo.ns.cloudflare.com conductor.makefile.so -> 192.210.137.113`
 41-- `@giancarlo.ns.cloudflare.com mini-conductor.makefile.so -> 192.210.137.113`
 42-- `@giancarlo.ns.cloudflare.com mac-conductor.makefile.so -> 192.210.137.113`
 43-
 44-## VPS / Nginx
 45-
 46-VPS facts:
 47-
 48-- host: `racknerd-ff37952`
 49-- SSH: `root@192.210.137.113 -p 2222`
 50-- Tailscale IPv4: `100.68.201.85`
 51-- Nginx: `1.24.0`
 52-
 53-Packages installed during this rollout:
 54-
 55-- `apache2-utils`
 56-- `python3-certbot-dns-cloudflare`
 57-
 58-TLS and auth:
 59-
 60-- Cloudflare DNS challenge credentials written to `/root/.secrets/certbot/cloudflare.ini`
 61-- `certbot certonly --authenticator dns-cloudflare ... --cert-name conductor.makefile.so -d conductor.makefile.so`
 62-- `certbot certonly --authenticator dns-cloudflare ... --cert-name mini-conductor.makefile.so -d mini-conductor.makefile.so`
 63-- `certbot certonly --authenticator dns-cloudflare ... --cert-name mac-conductor.makefile.so -d mac-conductor.makefile.so`
 64-- all three certificates expire on `2026-06-19`
 65-- Basic Auth file created at `/etc/nginx/.htpasswd-baa-conductor`
 66-- local secret copy stored outside the repo at `/Users/george/.config/baa-conductor/direct-node-basic-auth.env`
 67-
 68-Nginx deployment:
 69-
 70-- bundle rendered from `/Users/george/code/baa-conductor-T028-v2/.tmp/ops/baa-conductor-nginx`
 71-- synced to `/tmp/baa-conductor-nginx` on the VPS
 72-- `./deploy-on-vps.sh` passed `nginx -t`
 73-- `./deploy-on-vps.sh --reload` passed and reloaded Nginx
 74-
 75-## Public verification
 76-
 77-Control plane:
 78-
 79-- `curl -H "Authorization: Bearer $CONTROL_API_OPS_ADMIN_TOKEN" https://control-api.makefile.so/v1/system/state`
 80-- result: `ok=true`, `holder_id=mini-main`, `mode=running`
 81-
 82-Pre-cutover ingress verification against the fresh VPS config:
 83-
 84-- `curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/healthz -> ok`
 85-- `curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/rolez -> leader`
 86-- `curl --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/healthz -> 401` without auth
 87-- `curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/healthz -> ok`
 88-- `curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/rolez -> leader`
 89-- `curl --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/healthz -> 401` without auth
 90-- `curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/healthz -> ok`
 91-- `curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/rolez -> standby`
 92-
 93-VPS upstream probes:
 94-
 95-- `curl http://100.71.210.78:4317/healthz -> ok`
 96-- `curl http://100.112.239.13:4317/healthz -> ok`
 97-- `curl http://100.71.210.78:4318/healthz -> ok`
 98-- `curl http://100.112.239.13:4318/healthz -> ok`
 99-
100-## Follow-up
101-
102-- If these three conductor domains need to go back behind Cloudflare proxy, use a token that can change zone SSL settings and switch the zone from `Flexible` to `Full` or `Full (strict)` before turning `proxied=true` back on.
D docs/ops/real-stability-regression-2026-03-22.md
+0, -236
  1@@ -1,236 +0,0 @@
  2-# 2026-03-22 Real Stability Regression
  3-
  4-## Scope
  5-
  6-This pass reused the live environment from `T-028` instead of redeploying:
  7-
  8-- `control-api.makefile.so`
  9-- `conductor.makefile.so`
 10-- `mini-conductor.makefile.so`
 11-- `mac-conductor.makefile.so`
 12-- `mini` on Tailscale `100.71.210.78`
 13-- `mac` on Tailscale `100.112.239.13`
 14-- VPS `192.210.137.113`
 15-
 16-The goal was a real live-environment regression on smoke, observation, planned failover, switchback, and residual risk capture.
 17-
 18-## Environment Snapshot
 19-
 20-- authoritative DNS at `@giancarlo.ns.cloudflare.com` returned `192.210.137.113` for all three conductor hosts during this pass
 21-- both installed launchd plists still point at `/Users/george/code/baa-conductor-T028-v2`, not `/Users/george/code/baa-conductor`
 22-- `ps` showed no active `worker-runner` process on either node before planned failover
 23-- origin TLS on `192.210.137.113:443` currently presents these expiry dates:
 24-  - `conductor.makefile.so`: `notAfter=Jun 19 18:17:37 2026 GMT`
 25-  - `mini-conductor.makefile.so`: `notAfter=Jun 19 18:18:23 2026 GMT`
 26-  - `mac-conductor.makefile.so`: `notAfter=Jun 19 18:19:28 2026 GMT`
 27-
 28-## Baseline Smoke
 29-
 30-Executed on 2026-03-22 CST before failover:
 31-
 32-```bash
 33-./scripts/failover/print-topology.sh --env /Users/george/.config/baa-conductor/ops.env
 34-
 35-./scripts/failover/rehearsal-check.sh \
 36-  --env /Users/george/.config/baa-conductor/ops.env \
 37-  --basic-auth "$DIRECT_BASIC_AUTH" \
 38-  --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" \
 39-  --expect-leader mini
 40-
 41-node scripts/smoke/live-regression.mjs \
 42-  --env /Users/george/.config/baa-conductor/ops.env \
 43-  --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env \
 44-  --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env \
 45-  --expect-leader mini
 46-
 47-./scripts/runtime/check-node.sh \
 48-  --repo-dir /Users/george/code/baa-conductor-T028-v2 \
 49-  --node mini \
 50-  --service conductor \
 51-  --service status-api \
 52-  --install-dir /Users/george/Library/LaunchAgents \
 53-  --local-api-base http://100.71.210.78:4317 \
 54-  --local-api-allowed-hosts 100.71.210.78 \
 55-  --status-api-base http://100.71.210.78:4318 \
 56-  --status-api-host 100.71.210.78 \
 57-  --expected-rolez leader \
 58-  --check-loaded
 59-
 60-ssh george@100.112.239.13 \
 61-  'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/check-node.sh \
 62-    --repo-dir /Users/george/code/baa-conductor-T028-v2 \
 63-    --node mac \
 64-    --service conductor \
 65-    --service status-api \
 66-    --install-dir /Users/george/Library/LaunchAgents \
 67-    --local-api-base http://100.112.239.13:4317 \
 68-    --local-api-allowed-hosts 100.112.239.13 \
 69-    --status-api-base http://100.112.239.13:4318 \
 70-    --status-api-host 100.112.239.13 \
 71-    --expected-rolez standby \
 72-    --check-loaded'
 73-```
 74-
 75-Observed baseline:
 76-
 77-- `control-api /v1/system/state` returned `holder_id=mini-main`, `mode=running`, `term=1`
 78-- public ingress returned `healthz=ok`, `readyz=ready`, `rolez=leader`
 79-- `mini` direct host returned `healthz=ok`, `readyz=ready`, `rolez=leader`
 80-- `mac` direct host returned `healthz=ok`, `readyz=ready`, `rolez=standby`
 81-- unauthenticated direct-host probes returned `401`
 82-- both `check-node.sh` passes succeeded against the live `T028-v2` runtime
 83-
 84-Smoke did not fully pass:
 85-
 86-- both `http://100.71.210.78:4318/v1/status` and `http://100.112.239.13:4318/v1/status` returned `ok=true`
 87-- but both payloads stayed at `source=empty`, `mode=paused`, `leaderId=null`
 88-- this did not match the live control plane state (`mini-main`, `running`)
 89-
 90-## 30-Minute Observation Window
 91-
 92-Sampling method:
 93-
 94-- command: `node scripts/smoke/live-regression.mjs ... --expect-leader mini --compact`
 95-- sample count: `7`
 96-- interval: `300s`
 97-- raw log: `/tmp/t029-observation-20260322.jsonl`
 98-- window: `2026-03-22 03:43:28 CST` to `2026-03-22 04:13:44 CST` (`30.3` minutes)
 99-
100-Observed across all 7 samples:
101-
102-- control plane holder stayed `mini-main`
103-- control plane mode stayed `running`
104-- public `/rolez` stayed `leader`
105-- `mini` direct `/rolez` stayed `leader`
106-- `mac` direct `/rolez` stayed `standby`
107-- no `healthz` or `readyz` failure was observed on public or direct conductor hosts
108-- direct-host Basic Auth remained enforced
109-- the only repeated smoke failure was status drift:
110-  - `mini status-api`: `source=empty`, `mode=paused`
111-  - `mac status-api`: `source=empty`, `mode=paused`
112-
113-## Planned Failover
114-
115-Preconditions captured before cutover:
116-
117-- no `worker-runner` process on `mini`
118-- no `worker-runner` process on `mac`
119-- note: because `status-api` was already stale, active-run confirmation had to rely on host process checks instead of `/v1/status`
120-
121-Commands used:
122-
123-```bash
124-curl -X POST \
125-  -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
126-  -H 'Content-Type: application/json' \
127-  -d '{"requested_by":"T-029","reason":"planned_failover_rehearsal"}' \
128-  https://control-api.makefile.so/v1/system/drain
129-
130-curl -X POST \
131-  -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
132-  -H 'Content-Type: application/json' \
133-  -d '{"requested_by":"T-029","reason":"planned_failover_cutover"}' \
134-  https://control-api.makefile.so/v1/system/pause
135-
136-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
137-```
138-
139-Timeline in CST:
140-
141-- `04:14:48`: `drain` accepted
142-- `04:15:03`: control plane confirmed `mode=paused`
143-- `04:15:09`: `mini` conductor booted out from launchd
144-- `04:15:25`: first post-stop probe showed `public /rolez=standby`, `mac /rolez=standby`, control holder still `mini-main`
145-- `04:15:40`: second probe showed `public /rolez=standby`, `mac /rolez=leader`, control holder `mac-standby`, `term=2`
146-- `04:15:54`: third probe showed `public /rolez=leader`, `mac /rolez=leader`, control holder `mac-standby`, `term=2`
147-- `04:16:29`: authenticated `mini-conductor.makefile.so/healthz` returned `502`; `curl --noproxy '*' http://100.71.210.78:4317/healthz` failed with curl `7` / HTTP `000`
148-- `04:17:00`: `resume` accepted and control plane returned `mode=running`, `holder_id=mac-standby`
149-
150-Failover conclusion:
151-
152-- planned failover worked on the real environment
153-- lease moved from `mini-main` to `mac-standby`
154-- public ingress recovered back to `leader` without DNS edits
155-- there was a real transient cutover window after stopping `mini`
156-  - public `/rolez` was observed as `standby`
157-  - the window was still present at `04:15:40`
158-  - it had cleared by `04:15:54`
159-
160-## Switchback
161-
162-Commands used:
163-
164-```bash
165-curl -X POST \
166-  -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
167-  -H 'Content-Type: application/json' \
168-  -d '{"requested_by":"T-029","reason":"switchback_prepare"}' \
169-  https://control-api.makefile.so/v1/system/pause
170-
171-ssh george@100.112.239.13 \
172-  'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
173-
174-cd /Users/george/code/baa-conductor-T028-v2
175-./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents
176-```
177-
178-Timeline in CST:
179-
180-- `04:17:17`: switchback `pause` accepted
181-- `04:17:24`: `mac` conductor booted out; `mini` conductor reload completed
182-- `04:17:41`: first probe showed `public /rolez=standby`, `mini /rolez=standby`, control holder still `mac-standby`
183-- by the next probe window, control holder had returned to `mini-main`, `term=3`, and both public/mini `/rolez` were back to `leader`
184-- `04:18:08`: `resume` accepted and control plane returned `mode=running`, `holder_id=mini-main`
185-
186-Switchback was not fully closed at that point:
187-
188-- `04:18:23` snapshot still showed `mac-conductor.makefile.so` returning `502`
189-- that was expected from the runbook sequence because `mac` had been stopped, but it meant the topology was not yet back to `mini leader + mac standby`
190-
191-Canonical topology restoration required one more explicit step:
192-
193-```bash
194-ssh george@100.112.239.13 \
195-  'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh \
196-    --service conductor \
197-    --install-dir /Users/george/Library/LaunchAgents'
198-```
199-
200-Observed after reloading `mac`:
201-
202-- `04:18:41`: remote reload started
203-- first remote `check-node.sh` after reload saw the `mac` conductor process but reported `conductor is not listening on TCP port 4317`
204-- a few seconds later, `rehearsal-check.sh` showed `mac-conductor.makefile.so` back at `rolez=standby`
205-- `04:19:51`: final `live-regression.mjs` snapshot showed public/direct roles back to `leader / leader / standby`
206-- final local and remote `check-node.sh` runs both passed
207-
208-Switchback conclusion:
209-
210-- control plane and public ingress successfully returned to `mini-main`, `term=3`
211-- restoring `mac` as standby required an explicit post-switchback reload
212-- there was a short runtime window where the process existed before `4317` was actually listening
213-
214-## Current Findings
215-
216-### Confirmed working
217-
218-- authoritative DNS still points all conductor hosts at the VPS public IP
219-- public ingress stayed available through planned failover and switchback
220-- leader lease moved `mini -> mac -> mini`
221-- direct-host Basic Auth stayed enforced
222-- both nodes can return to a healthy `leader / standby` split after explicit reloads
223-
224-### Confirmed regression
225-
226-- both `status-api /v1/status` endpoints are stale in live
227-- they continue to report `source=empty`, `mode=paused`, `leaderId=null`
228-- this remained true before failover, during failover, after switchback, and after both nodes re-entered healthy roles
229-
230-## Residual Risks
231-
232-- `status-api` is not a reliable truth source in the live environment right now; it cannot be used to decide whether automation is really `running` or whether leader identity has updated.
233-- installed launchd plists on both nodes still target `/Users/george/code/baa-conductor-T028-v2`; the runtime has not been normalized to the canonical repo path.
234-- switchback is not a one-command return to `mini leader + mac standby`; after moving leadership back to `mini`, `mac` still needed an explicit `reload-launchd.sh --service conductor`.
235-- there is a real short-lived public inconsistency window during both failover and switchback where `/rolez` can read `standby` before the next leader is externally visible as `leader`.
236-- this rollout is still operating in DNS-only mode from the authoritative DNS perspective; Cloudflare proxy is not back in service for the conductor hosts.
237-- local non-authoritative resolver output was inconsistent with authoritative DNS for some hostnames during this task, which is another reminder that local DNS/proxy layers can mislead direct diagnostics.
D docs/ops/switchback.md
+0, -145
  1@@ -1,145 +0,0 @@
  2-# Switchback Runbook
  3-
  4-适用场景:
  5-
  6-- emergency 或 planned failover 之后,当前 leader 在 `mac`
  7-- `mini` 已修复,准备把系统恢复到 canonical 的“mini 主、mac 备”
  8-
  9-switchback 的重点不是“尽快让 mini 上线”,而是“把临时状态清干净,再回到可重复的默认形态”。
 10-
 11-## 1. 先修 mini,不先切流量
 12-
 13-在 `mini` 上先把基础面校验完:
 14-
 15-```bash
 16-cd /Users/george/code/baa-conductor
 17-./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor
 18-npx --yes pnpm -r build
 19-./scripts/runtime/check-launchd.sh \
 20-  --repo-dir /Users/george/code/baa-conductor \
 21-  --node mini \
 22-  --install-dir "$HOME/Library/LaunchAgents"
 23-```
 24-
 25-如有需要,重渲染并重载安装副本:
 26-
 27-```bash
 28-cd /Users/george/code/baa-conductor
 29-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
 30-./scripts/runtime/reload-launchd.sh
 31-```
 32-
 33-在还没停掉 mac 之前,mini 即使已经恢复,也可能仍只是 `standby` 或暂时拿不到 lease,这是正常的。
 34-
 35-## 2. 先 pause,再移交
 36-
 37-switchback 前先暂停自动化:
 38-
 39-```bash
 40-export CONTROL_API_BASE='https://control-api.makefile.so'
 41-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
 42-
 43-curl -sS -X POST \
 44-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 45-  -H 'Content-Type: application/json' \
 46-  -d '{"requested_by":"ops_runbook","reason":"switchback_prepare"}' \
 47-  "${CONTROL_API_BASE%/}/v1/system/pause"
 48-```
 49-
 50-## 3. 停掉 mac conductor
 51-
 52-为了让 lease 和公网入口都回到 mini,需要先让 mac conductor 退下。
 53-
 54-默认 `LaunchAgents`:
 55-
 56-```bash
 57-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
 58-```
 59-
 60-若使用 `LaunchDaemons`:
 61-
 62-```bash
 63-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
 64-```
 65-
 66-然后在 mini 上确保 conductor 重新 bootstrap:
 67-
 68-```bash
 69-cd /Users/george/code/baa-conductor
 70-./scripts/runtime/reload-launchd.sh
 71-```
 72-
 73-## 4. 恢复 canonical Nginx
 74-
 75-如果 emergency failover 时对 VPS 做过热修,switchback 必须用 repo 的 canonical 配置覆盖回去。
 76-
 77-生成 bundle:
 78-
 79-```bash
 80-scripts/ops/nginx-sync-plan.sh \
 81-  --env ../baa-conductor.ops.env \
 82-  --bundle-dir .tmp/ops/baa-conductor-nginx
 83-```
 84-
 85-分发并 reload:
 86-
 87-```bash
 88-rsync -av .tmp/ops/baa-conductor-nginx/ root@YOUR_VPS:/tmp/baa-conductor-nginx/
 89-ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
 90-```
 91-
 92-这样会把 VPS 配置恢复成:
 93-
 94-- mini `100.71.210.78:4317` 为 primary
 95-- mac `100.112.239.13:4317` 为 backup
 96-
 97-## 5. 验证已经回到 mini
 98-
 99-执行:
100-
101-```bash
102-./scripts/failover/rehearsal-check.sh \
103-  --env ../baa-conductor.ops.env \
104-  --basic-auth 'conductor-ops:REPLACE_ME' \
105-  --bearer-token 'REPLACE_ME' \
106-  --skip-node mac \
107-  --expect-leader mini
108-```
109-
110-成功条件:
111-
112-- `conductor.makefile.so` 返回 `leader`
113-- `mini-conductor.makefile.so` 返回 `leader`
114-- `GET /v1/system/state` 的 `holder_id` 以 `mini-` 开头
115-
116-如果这一步失败,不要急着 `resume`,先查:
117-
118-- mini 的 `launchctl print`
119-- mini 的 `logs/launchd/so.makefile.baa-conductor.err.log`
120-- VPS 上是否还有 emergency 热修残留
121-
122-## 6. 恢复自动化
123-
124-确认 switchback 完成后,再 resume:
125-
126-```bash
127-curl -sS -X POST \
128-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
129-  -H 'Content-Type: application/json' \
130-  -d '{"requested_by":"ops_runbook","reason":"switchback_complete"}' \
131-  "${CONTROL_API_BASE%/}/v1/system/resume"
132-```
133-
134-## 7. 收尾检查
135-
136-switchback 后建议立刻补做一次完整基线:
137-
138-```bash
139-./scripts/failover/rehearsal-check.sh \
140-  --env ../baa-conductor.ops.env \
141-  --basic-auth 'conductor-ops:REPLACE_ME' \
142-  --bearer-token 'REPLACE_ME' \
143-  --expect-leader mini
144-```
145-
146-如果这份结果和正常基线一致,说明系统已经回到默认拓扑。
M docs/runtime/README.md
+15, -31
 1@@ -1,41 +1,25 @@
 2 # runtime
 3 
 4-本目录定义当前推荐的 `mini` 单节点 runtime 约定:目录布局、环境变量,以及 `scripts/runtime/*.sh` 驱动的 `launchd` 安装方式。
 5-
 6-当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
 7-
 8-这仍然不代表所有长期服务都已经完成运行时接线。当前阶段解决的是“产物路径存在且一致”,而不是“所有 daemon/worker 逻辑都已可直接上线”。
 9+当前 runtime 只定义 `mini` 单节点的长期运行方式。
10 
11 ## 内容
12 
13-- [`layout.md`](./layout.md): `runs/`、`worktrees/`、`logs/`、`tmp/` 与 `state/` 的初始化方式和生命周期
14-- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量,以及安装脚本如何覆盖默认值
15-- [`launchd.md`](./launchd.md): `mini` 的脚本化安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
16-- [`node-verification.md`](./node-verification.md): `mini` 节点的 on-node 验证顺序、期望探针结果,以及日志/进程检查点
17+- [`layout.md`](./layout.md): runtime 目录布局
18+- [`environment.md`](./environment.md): 必要环境变量
19+- [`launchd.md`](./launchd.md): `mini` 上的 launchd 安装
20+- [`node-verification.md`](./node-verification.md): `mini` 节点 on-node 检查
21 
22-## 统一约定
23+## 当前约定
24 
25-- 当前只保留 `mini` 作为长期运行节点,默认跑 `so.makefile.baa-conductor`。
26-- `mac` 不再作为备主节点要求的一部分;相关说明只保留作历史参考。
27-- 推荐把运行中的仓库放在 `/Users/george/code/baa-conductor`,这样 repo 内的 plist 源模板可以直接复用设计里的绝对路径。
28-- repo 中的 plist 只作为源模板;真正加载的是 `scripts/runtime/install-launchd.sh` 复制并改写到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
29+- 长期运行节点只有 `mini`
30+- 推荐仓库路径:`/Users/george/code/baa-conductor`
31+- repo 内的 plist 只作为模板;真正加载的是脚本渲染出来的安装副本
32 
33 ## 最短路径
34 
35-1. 先按 [`layout.md`](./layout.md) 运行 `./scripts/runtime/bootstrap.sh` 初始化 runtime 根目录。
36-2. 再按 [`environment.md`](./environment.md) 准备共享变量和 `mini` 节点变量,特别是 `BAA_SHARED_TOKEN`。
37-3. 按 [`launchd.md`](./launchd.md) 运行 `install-launchd.sh` 生成安装副本,先用 `check-launchd.sh` 做静态校验。
38-4. 在节点上已有实际进程后,再按 [`node-verification.md`](./node-verification.md) 运行 `check-node.sh` 做端口、探针、status-api 宿主进程与日志路径检查。
39-5. 每次准备执行 `check-launchd.sh` 的 dist 校验、`check-node.sh` 的进程检查,或真正 `launchctl bootstrap` 前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
40-
41-## 当前脚本集
42-
43-- `scripts/runtime/bootstrap.sh`: 预创建 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`tmp/`
44-- `scripts/runtime/install-launchd.sh`: 从 `ops/launchd/*.plist` 渲染实际安装副本
45-- `scripts/runtime/check-launchd.sh`: 校验源模板、runtime 目录、构建产物,以及已安装的 plist 副本
46-- `scripts/runtime/check-node.sh`: 在节点上校验 launchd 副本之外的运行态信号,例如本地端口、HTTP 探针、status-api 宿主进程与 launchd 日志文件
47-- `scripts/runtime/reload-launchd.sh`: 执行或 dry-run `launchctl bootout/bootstrap/kickstart`
48-
49-默认安装/检查/重载集合只包含 `conductor`。如果后续要把其它模板也纳入流程,显式加 `--service worker-runner`、`--service status-api`,或直接使用 `--all-services`。
50-
51-`check-node.sh` 的默认集合不同:它默认同时检查 `conductor` 和 `status-api`,因为真实节点验证至少要覆盖本地控制面和状态面两条路径。如果节点暂时不跑 `status-api`,再显式收窄到 `--service conductor`。
52+1. `./scripts/runtime/bootstrap.sh`
53+2. `npx --yes pnpm -r build`
54+3. `./scripts/runtime/install-launchd.sh --node mini`
55+4. `./scripts/runtime/check-launchd.sh --node mini`
56+5. `./scripts/runtime/reload-launchd.sh`
57+6. `./scripts/runtime/check-node.sh --node mini`
M docs/runtime/environment.md
+22, -97
  1@@ -1,111 +1,36 @@
  2-# environment
  3+# runtime environment
  4 
  5-## 原则
  6+当前只保留 `mini` 单节点变量。
  7 
  8-- `launchd` 不会自动读取 `.zshrc`、`.zprofile` 或 repo 根目录下的 `.env`。
  9-- plist 里出现的路径都必须是绝对路径,不能依赖 `~`、`$HOME` 或 shell 展开。
 10-- `BAA_SHARED_TOKEN` 必须在安装副本里替换成真实值,repo 内源模板保持 `replace-me`。
 11-- `conductor` 与 `status-api` 当前都只绑定一个 listen host;默认是 loopback,真实节点如需给 VPS 回源,必须显式切到对应的 Tailscale `100.x`。
 12-- 如果 `mini` 与 `mac` 都使用 `/Users/george/code/baa-conductor`,目录变量可以完全一致,只有节点身份变量需要区分。
 13+## 共享变量
 14 
 15-## 应用层变量
 16+- `BAA_CONTROL_API_BASE`
 17+- `BAA_SHARED_TOKEN`
 18+- `BAA_RUNS_DIR`
 19+- `BAA_WORKTREES_DIR`
 20+- `BAA_LOGS_DIR`
 21+- `BAA_TMP_DIR`
 22+- `BAA_STATE_DIR`
 23 
 24-| 变量 | `mini` | `mac` | 说明 |
 25-| --- | --- | --- | --- |
 26-| `BAA_CONDUCTOR_HOST` | `mini` | `mac` | 节点标识;应与节点域名和故障切换语义一致 |
 27-| `BAA_CONDUCTOR_ROLE` | `primary` | `standby` | conductor 期望角色;切回主节点前不要让 `mac` 默认成 `primary` |
 28-| `BAA_CONTROL_API_BASE` | `https://control-api.makefile.so` | `https://control-api.makefile.so` | 控制平面统一入口 |
 29-| `BAA_CONDUCTOR_LOCAL_API` | `http://127.0.0.1:4317` | `http://127.0.0.1:4317` | 默认 loopback;真实 Tailscale 回源时改成节点自己的 `http://100.x:4317` |
 30-| `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` | 空 | 空 | 仅当 `BAA_CONDUCTOR_LOCAL_API` 改成 Tailscale `100.x` 时填写;必须显式列出允许绑定的 host |
 31-| `BAA_STATUS_API_HOST` | `127.0.0.1` | `127.0.0.1` | `status-api` 默认 loopback;真实节点如需 Tailscale 访问时改成节点自己的 `100.x` |
 32-| `BAA_RUNS_DIR` | `/Users/george/code/baa-conductor/runs` | `/Users/george/code/baa-conductor/runs` | run 根目录 |
 33-| `BAA_WORKTREES_DIR` | `/Users/george/code/baa-conductor/worktrees` | `/Users/george/code/baa-conductor/worktrees` | task worktree 根目录 |
 34-| `BAA_LOGS_DIR` | `/Users/george/code/baa-conductor/logs` | `/Users/george/code/baa-conductor/logs` | 服务日志根目录 |
 35-| `BAA_TMP_DIR` | `/Users/george/code/baa-conductor/tmp` | `/Users/george/code/baa-conductor/tmp` | 临时目录根 |
 36-| `BAA_STATE_DIR` | `/Users/george/code/baa-conductor/state` | `/Users/george/code/baa-conductor/state` | 本地状态镜像目录 |
 37-| `BAA_NODE_ID` | `mini-main` | `mac-standby` | 节点实例 ID;应稳定且可用于 lease/日志归因 |
 38-| `BAA_SHARED_TOKEN` | 安装时注入 | 安装时注入 | 节点到 control API 的共享认证口令 |
 39-
 40-`scripts/runtime/install-launchd.sh` 会把这些路径写进安装副本;如果 repo 根目录不在 `/Users/george/code/baa-conductor`,直接通过 `--repo-dir` 派生新的路径,不需要再手工改每个 plist。
 41-
 42-## Tailscale rollout 覆盖值
 43-
 44-默认表格保持的是安全的 loopback 基线。真实节点要让 VPS 通过 Tailscale `100.x` 回源时,覆盖成下面这样:
 45-
 46-`mini`:
 47+## 节点变量
 48 
 49 ```text
 50+BAA_CONDUCTOR_HOST=mini
 51+BAA_CONDUCTOR_ROLE=primary
 52+BAA_NODE_ID=mini-main
 53 BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317
 54 BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78
 55 BAA_STATUS_API_HOST=100.71.210.78
 56 ```
 57 
 58-`mac`:
 59-
 60-```text
 61-BAA_CONDUCTOR_LOCAL_API=http://100.112.239.13:4317
 62-BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.112.239.13
 63-BAA_STATUS_API_HOST=100.112.239.13
 64-```
 65-
 66-约束说明:
 67-
 68-- `BAA_CONDUCTOR_LOCAL_API` 仍只接受 `http://`,且 host 只能是 loopback 或 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` 里显式列出的 Tailscale `100.x`
 69-- `BAA_STATUS_API_HOST` 默认不需要改;只有确实要通过 Tailscale 直连 `status-api` 时才切到 `100.x`
 70-- 当前实现不是双监听;切到 `100.x` 后,`check-node.sh` 也要跟着传 `--local-api-base` / `--status-api-base`
 71-
 72-## launchd 辅助变量
 73-
 74-| 变量 | 推荐值 | 说明 |
 75-| --- | --- | --- |
 76-| `PATH` | `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/george/.local/bin:/Users/george/bin` | 让 `node`、`git`、`pnpm`、`codex` 等命令在 `launchd` 环境里可发现 |
 77-| `HOME` | `/Users/george` | 给后续子进程一个稳定的 home 目录 |
 78-| `LANG` | `en_US.UTF-8` | 保证日志和文本处理统一用 UTF-8 |
 79-| `LC_ALL` | `en_US.UTF-8` | 避免不同 locale 造成命令输出差异 |
 80-
 81-这些变量属于 `launchd` 安装层面的补充,不代表当前应用代码已经消费了全部字段。模板里统一保留它们,是为了避免后续服务落地时再拆分不同的 plist 版本。
 82-
 83-`install-launchd.sh` 会同时根据 `--home-dir` 重写 `HOME` 和 `PATH`,避免模板里残留固定的 `/Users/george`。
 84-
 85-## 脚本输入约定
 86-
 87-`scripts/runtime/install-launchd.sh` / `check-launchd.sh` 目前主要消费这些输入:
 88-
 89-- `--node mini|mac`:决定 `BAA_CONDUCTOR_HOST`、`BAA_CONDUCTOR_ROLE`、`BAA_NODE_ID`
 90-- `--repo-dir PATH`:决定 `WorkingDirectory`、`BAA_*_DIR` 与 `ProgramArguments` 里的 dist 入口
 91-- `--home-dir PATH`:决定 `HOME`、`PATH` 与默认 `~/Library/LaunchAgents`
 92-- `--shared-token`、`--shared-token-file` 或环境变量 `BAA_SHARED_TOKEN`:写入或校验 `BAA_SHARED_TOKEN`
 93-- `--control-api-base`、`--local-api-base`:覆盖默认控制平面地址
 94-- `--local-api-allowed-hosts`:覆盖 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS`
 95-- `--status-api-host`:覆盖 `BAA_STATUS_API_HOST`
 96-
 97-## 节点最小差异集
 98-
 99-如果两台机器的 repo 路径一致,`mac` 相对 `mini` 最少只要改这三项:
100-
101-```text
102-BAA_CONDUCTOR_HOST=mac
103-BAA_CONDUCTOR_ROLE=standby
104-BAA_NODE_ID=mac-standby
105-```
106-
107-此外,conductor plist 里的 CLI 参数也要同步从:
108-
109-```text
110---host mini --role primary
111-```
112-
113-改成:
114-
115-```text
116---host mac --role standby
117-```
118-
119-脚本化安装时,这些差异不需要手工改 plist;改成:
120+## 最小例子
121 
122 ```bash
123-BAA_SHARED_TOKEN='replace-with-real-token'
124-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
125+./scripts/runtime/install-launchd.sh \
126+  --repo-dir /Users/george/code/baa-conductor \
127+  --node mini \
128+  --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
129+  --local-api-base http://100.71.210.78:4317 \
130+  --local-api-allowed-hosts 100.71.210.78 \
131+  --status-api-host 100.71.210.78
132 ```
133-
134-即可把安装副本改成 `mac` 的节点身份。
M docs/runtime/launchd.md
+30, -295
  1@@ -1,338 +1,73 @@
  2 # launchd
  3 
  4-## 服务集合
  5+当前只记录 `mini` 的 launchd 安装路径。
  6 
  7-repo 里保留了三个源模板:
  8-
  9-- `so.makefile.baa-conductor`
 10-- `so.makefile.baa-worker-runner`
 11-- `so.makefile.baa-status-api`
 12-
 13-当前脚本化流程默认只安装和重载 `conductor`。如果要把其它模板也纳入流程,显式加:
 14-
 15-- `--service worker-runner`
 16-- `--service status-api`
 17-- 或直接用 `--all-services`
 18-
 19-这样可以先把已接成 CLI/runtime 入口的服务跑通,再按需扩展其他模板。
 20-
 21-需要注意两个脚本默认集合不同:
 22-
 23-- `install-launchd.sh` / `check-launchd.sh` / `reload-launchd.sh` 默认只处理 `conductor`
 24-- `check-node.sh` 默认同时处理 `conductor` 和 `status-api`,因为节点验证至少要覆盖本地控制面与状态面
 25-
 26-repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 27-
 28-- repo 根目录:`/Users/george/code/baa-conductor`
 29-- 登录用户:`/Users/george`
 30-- conductor 参数:`--host mini --role primary`
 31-- token:`replace-me`
 32-
 33-安装前至少检查:
 34-
 35-- `BAA_SHARED_TOKEN` 已替换成真实值
 36-- `logs/launchd/` 已按 [`layout.md`](./layout.md) 创建
 37-- 如果 repo 不在 `/Users/george/code/baa-conductor`,同步改 `WorkingDirectory`、`ProgramArguments`、`HOME`、`StandardOutPath`、`StandardErrorPath`
 38-
 39-实际安装时推荐让脚本做这些改写,而不是手工编辑 plist。
 40-
 41-## 监听策略
 42-
 43-默认安装副本保持安全的 loopback 监听:
 44-
 45-- conductor: `http://127.0.0.1:4317`
 46-- status-api: `http://127.0.0.1:4318`
 47-
 48-真实 rollout 如果要让 VPS 直接回源到节点,就显式切到对应节点的 Tailscale `100.x`:
 49-
 50-- `mini`: conductor `http://100.71.210.78:4317`,status-api `http://100.71.210.78:4318`
 51-- `mac`: conductor `http://100.112.239.13:4317`,status-api `http://100.112.239.13:4318`
 52-
 53-当前实现不是双监听。切到 Tailscale 后,需要同时设置:
 54-
 55-- `--local-api-base`
 56-- `--local-api-allowed-hosts`
 57-- `--status-api-host`
 58-
 59-## `LaunchAgents` 与 `LaunchDaemons`
 60-
 61-| 目标路径 | 适合场景 | 优点 | 额外要求 |
 62-| --- | --- | --- | --- |
 63-| `~/Library/LaunchAgents/` | 开发机、自用节点、登录后再启动即可 | 不需要 `sudo`,调试最直接 | 只会在用户登录后启动 |
 64-| `/Library/LaunchDaemons/` | 需要开机登录前就启动 | 适合 headless 常驻 | plist 必须 `root:wheel` 且通常要额外加 `UserName` |
 65-
 66-默认推荐先用 `LaunchAgents` 跑通,再按需切到 `LaunchDaemons`。
 67-
 68-## mini 安装
 69-
 70-`mini` 可以直接复用 repo 模板的大部分默认值。推荐顺序如下。
 71-
 72-### 1. 初始化 runtime 根目录
 73-
 74-```bash
 75-REPO_DIR=/Users/george/code/baa-conductor
 76-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
 77-```
 78-
 79-### 2. 构建 dist 入口
 80+## 1. 构建
 81 
 82 ```bash
 83-cd /Users/george/code/baa-conductor
 84 npx --yes pnpm -r build
 85 ```
 86 
 87-### 3. 渲染安装副本
 88+## 2. 初始化 runtime 目录
 89 
 90 ```bash
 91-REPO_DIR=/Users/george/code/baa-conductor
 92-export BAA_SHARED_TOKEN='replace-with-real-token'
 93-
 94-./scripts/runtime/install-launchd.sh \
 95-  --repo-dir "$REPO_DIR" \
 96-  --node mini
 97+./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor
 98 ```
 99 
100-如果要同时渲染 runner/status 模板:
101+## 3. 渲染安装副本
102 
103 ```bash
104 ./scripts/runtime/install-launchd.sh \
105-  --repo-dir "$REPO_DIR" \
106-  --node mini \
107-  --all-services
108-```
109-
110-如果这台 `mini` 要给 VPS 真实回源,改成:
111-
112-```bash
113-./scripts/runtime/install-launchd.sh \
114-  --repo-dir "$REPO_DIR" \
115+  --repo-dir /Users/george/code/baa-conductor \
116   --node mini \
117-  --all-services \
118+  --service conductor \
119+  --service status-api \
120+  --install-dir /Users/george/Library/LaunchAgents \
121+  --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
122+  --control-api-base https://control-api.makefile.so \
123   --local-api-base http://100.71.210.78:4317 \
124   --local-api-allowed-hosts 100.71.210.78 \
125   --status-api-host 100.71.210.78
126 ```
127 
128-### 4. 静态校验安装副本
129-
130-```bash
131-REPO_DIR=/Users/george/code/baa-conductor
132-AGENTS_DIR="$HOME/Library/LaunchAgents"
133-
134-./scripts/runtime/check-launchd.sh \
135-  --repo-dir "$REPO_DIR" \
136-  --node mini \
137-  --install-dir "$AGENTS_DIR"
138-```
139-
140-如果安装副本已经切到 Tailscale `100.x`,校验时也要带同一组覆盖值:
141+## 4. 静态校验
142 
143 ```bash
144 ./scripts/runtime/check-launchd.sh \
145-  --repo-dir "$REPO_DIR" \
146+  --repo-dir /Users/george/code/baa-conductor \
147   --node mini \
148-  --all-services \
149-  --install-dir "$AGENTS_DIR" \
150+  --service conductor \
151+  --service status-api \
152+  --install-dir /Users/george/Library/LaunchAgents \
153+  --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
154+  --control-api-base https://control-api.makefile.so \
155   --local-api-base http://100.71.210.78:4317 \
156   --local-api-allowed-hosts 100.71.210.78 \
157   --status-api-host 100.71.210.78
158 ```
159 
160-### 5. on-node 验证
161-
162-`check-launchd.sh` 只覆盖静态层。节点上已经有真实进程后,再补一轮 on-node 检查:
163+## 5. 重载
164 
165 ```bash
166-REPO_DIR=/Users/george/code/baa-conductor
167-AGENTS_DIR="$HOME/Library/LaunchAgents"
168-
169-./scripts/runtime/check-node.sh \
170-  --repo-dir "$REPO_DIR" \
171-  --node mini \
172-  --all-services \
173-  --install-dir "$AGENTS_DIR" \
174-  --expected-rolez leader
175+./scripts/runtime/reload-launchd.sh \
176+  --install-dir /Users/george/Library/LaunchAgents \
177+  --service conductor \
178+  --service status-api
179 ```
180 
181-如果 `mini` 已改成 Tailscale 监听,把 probe URL 也显式改掉:
182+## 6. 节点检查
183 
184 ```bash
185 ./scripts/runtime/check-node.sh \
186-  --repo-dir "$REPO_DIR" \
187+  --repo-dir /Users/george/code/baa-conductor \
188   --node mini \
189-  --all-services \
190-  --install-dir "$AGENTS_DIR" \
191+  --service conductor \
192+  --service status-api \
193+  --install-dir /Users/george/Library/LaunchAgents \
194   --local-api-base http://100.71.210.78:4317 \
195   --local-api-allowed-hosts 100.71.210.78 \
196   --status-api-base http://100.71.210.78:4318 \
197   --status-api-host 100.71.210.78 \
198-  --expected-rolez leader
199-```
200-
201-如果 `status-api` 暂时不在该节点常驻,把 `--all-services` 改成 `--service conductor`。
202-
203-### 6. 预览或执行重载
204-
205-```bash
206-./scripts/runtime/reload-launchd.sh --dry-run
207-```
208-
209-确认输出无误后,再去掉 `--dry-run` 真正执行。
210-
211-## mac 安装
212-
213-`mac` 与 `mini` 用同一组模板,但节点身份切到 standby。
214-
215-### 1. 初始化 runtime 根目录与构建
216-
217-```bash
218-REPO_DIR=/Users/george/code/baa-conductor
219-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
220-cd "$REPO_DIR"
221-npx --yes pnpm -r build
222-```
223-
224-### 2. 渲染 `mac` 安装副本
225-
226-```bash
227-REPO_DIR=/Users/george/code/baa-conductor
228-export BAA_SHARED_TOKEN='replace-with-real-token'
229-
230-./scripts/runtime/install-launchd.sh \
231-  --repo-dir "$REPO_DIR" \
232-  --node mac
233-```
234-
235-如果这台 `mac` 要作为 VPS 的 Tailscale 回源备用节点,改成:
236-
237-```bash
238-./scripts/runtime/install-launchd.sh \
239-  --repo-dir "$REPO_DIR" \
240-  --node mac \
241-  --all-services \
242-  --local-api-base http://100.112.239.13:4317 \
243-  --local-api-allowed-hosts 100.112.239.13 \
244-  --status-api-host 100.112.239.13
245+  --expected-rolez leader \
246+  --check-loaded
247 ```
248-
249-### 3. on-node 验证
250-
251-`mac` 的 steady-state 预期是 standby,因此把 `rolez` 预期值改成 `standby`:
252-
253-```bash
254-REPO_DIR=/Users/george/code/baa-conductor
255-AGENTS_DIR="$HOME/Library/LaunchAgents"
256-
257-./scripts/runtime/check-node.sh \
258-  --repo-dir "$REPO_DIR" \
259-  --node mac \
260-  --all-services \
261-  --install-dir "$AGENTS_DIR" \
262-  --expected-rolez standby
263-```
264-
265-如果 `mac` 已改成 Tailscale 监听,把验证地址同步切到 `100.112.239.13`:
266-
267-```bash
268-./scripts/runtime/check-node.sh \
269-  --repo-dir "$REPO_DIR" \
270-  --node mac \
271-  --all-services \
272-  --install-dir "$AGENTS_DIR" \
273-  --local-api-base http://100.112.239.13:4317 \
274-  --local-api-allowed-hosts 100.112.239.13 \
275-  --status-api-base http://100.112.239.13:4318 \
276-  --status-api-host 100.112.239.13 \
277-  --expected-rolez standby
278-```
279-
280-如果这是 failover rehearsal,把 `--expected-rolez standby` 改成 `--expected-rolez leader`。
281-
282-### 4. 加载服务
283-
284-静态校验和重载命令与 `mini` 相同,只是把 `--node mini` 换成 `--node mac`。
285-
286-## 切到 `LaunchDaemons`
287-
288-如果需要登录前启动,脚本流程改成:
289-
290-1. 用 `bootstrap.sh` 先准备 runtime 根目录
291-2. 用 `install-launchd.sh --scope daemon --username <user>` 渲染到 `/Library/LaunchDaemons/`
292-3. 如有需要,再手工 `sudo chown root:wheel /Library/LaunchDaemons/*.plist`
293-4. 用 `reload-launchd.sh --scope daemon` 或 `launchctl print system/...`
294-
295-示例:
296-
297-```bash
298-REPO_DIR=/Users/george/code/baa-conductor
299-export BAA_SHARED_TOKEN='replace-with-real-token'
300-
301-sudo ./scripts/runtime/install-launchd.sh \
302-  --repo-dir "$REPO_DIR" \
303-  --scope daemon \
304-  --node mini \
305-  --username george
306-
307-sudo ./scripts/runtime/check-launchd.sh \
308-  --repo-dir "$REPO_DIR" \
309-  --scope daemon \
310-  --node mini \
311-  --install-dir /Library/LaunchDaemons \
312-  --username george
313-
314-sudo ./scripts/runtime/reload-launchd.sh --scope daemon --dry-run
315-```
316-
317-使用 `LaunchDaemons` 时尤其要注意:
318-
319-- plist 里的 `HOME`、repo 路径和日志路径不能依赖当前登录用户的 shell
320-- runtime 根目录必须对 `UserName` 指定的账号可写
321-- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先通过安装脚本生成副本
322-
323-## 校验与排障
324-
325-静态校验:
326-
327-```bash
328-plutil -lint ops/launchd/so.makefile.baa-conductor.plist
329-plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist
330-plutil -lint ops/launchd/so.makefile.baa-status-api.plist
331-```
332-
333-脚本化校验:
334-
335-```bash
336-./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
337-./scripts/runtime/check-launchd.sh \
338-  --repo-dir /Users/george/code/baa-conductor \
339-  --node mini \
340-  --install-dir "$HOME/Library/LaunchAgents"
341-
342-./scripts/runtime/check-node.sh \
343-  --repo-dir /Users/george/code/baa-conductor \
344-  --node mini \
345-  --all-services \
346-  --install-dir "$HOME/Library/LaunchAgents" \
347-  --expected-rolez leader
348-```
349-
350-运行时排障常用命令:
351-
352-```bash
353-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
354-launchctl print "gui/$(id -u)/so.makefile.baa-worker-runner"
355-launchctl print "gui/$(id -u)/so.makefile.baa-status-api"
356-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conductor.err.log
357-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
358-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-status-api.err.log
359-```
360-
361-当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
362-
363-```bash
364-npx --yes pnpm -r build
365-```
366-
367-这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
368-
369-需要注意的是,当前脚本解决的是 runtime 目录、plist 渲染、静态检查与 `launchctl` 重载流程;它不替代各服务自己的运行时接线。这里解决的是部署路径与构建产物的一致性,而不是把所有业务进程都变成完整生产守护进程。
M docs/runtime/layout.md
+14, -70
 1@@ -1,83 +1,27 @@
 2 # runtime layout
 3 
 4-## 根路径
 5-
 6-推荐 `mini` 与 `mac` 都把仓库放在:
 7+推荐把 `mini` 上的仓库放在:
 8 
 9 ```text
10 /Users/george/code/baa-conductor
11 ```
12 
13-这样 `launchd` 模板里的绝对路径可以保持一致,只有节点身份变量需要区分。
14-
15-## 目录树
16-
17-```text
18-/Users/george/code/baa-conductor/
19-  state/
20-  runs/
21-  worktrees/
22-  logs/
23-    launchd/
24-  tmp/
25-```
26-
27-`state/` 是本地状态镜像目录,`runs/`、`worktrees/`、`logs/`、`tmp/` 是本任务需要先落地的 runtime 根目录。
28-
29-## 一次性初始化
30-
31-优先直接运行仓库内脚本:
32-
33-```bash
34-REPO_DIR=/Users/george/code/baa-conductor
35-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
36-```
37-
38-如果服务通过 `LaunchDaemons` 以专门账号运行,可以在初始化时直接带 owner:
39-
40-```bash
41-REPO_DIR=/Users/george/code/baa-conductor
42-sudo ./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR" --owner george:staff
43-```
44-
45-脚本本质上仍然只是执行 `install -d -m 700`,不会加载 `launchd` 服务,也不会修改 repo 之外的配置。
46-
47-## 目录职责
48-
49-| 路径 | 初始化方式 | 后续创建者 | 说明 |
50-| --- | --- | --- | --- |
51-| `state/` | `bootstrap.sh` 预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
52-| `runs/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
53-| `worktrees/` | `bootstrap.sh` 预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
54-| `logs/` | `bootstrap.sh` 预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
55-| `tmp/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
56-
57-## `runs/` 的细化约定
58-
59-每个 run 的目录建议保持:
60+运行时目录默认放在 repo 根目录内:
61 
62 ```text
63-runs/<task-id>/<run-id>/
64-  meta.json
65-  state.json
66-  stdout.log
67-  stderr.log
68-  worker.log
69-  checkpoints/
70-  artifacts/
71+state/
72+runs/
73+worktrees/
74+logs/
75+tmp/
76 ```
77 
78-根目录只预创建到 `runs/`。`<task-id>`、`<run-id>`、`checkpoints/` 与 `artifacts/` 都应由 conductor/worker 在实际执行时创建。
79-
80-## `worktrees/` 的细化约定
81-
82-- `worktrees/<task-id>/` 只在有活跃 task 时创建。
83-- 同一 task 只保留一个 worktree 路径,避免恢复时找错目录。
84-- 陈旧 worktree 只能由 conductor 或后续专门的清理流程回收,worker 不应自行删除。
85+说明:
86 
87-## `logs/` 与 `tmp/` 的边界
88+- `state/`: 本地状态和小型快照
89+- `runs/`: 单次 run 目录
90+- `worktrees/`: 独立 worktree
91+- `logs/launchd/`: launchd stdout/stderr
92+- `tmp/`: 脚本临时文件
93 
94-- `logs/launchd/*.out.log` 与 `logs/launchd/*.err.log` 只记录长期服务级日志。
95-- step 级 stdout/stderr 仍写回各自的 `runs/<task-id>/<run-id>/`。
96-- `tmp/` 只放临时 scratch 数据,不应作为 checkpoint 或 durable 状态目录。
97-- 任何清理 `tmp/` 或 `logs/` 的脚本都应在停服后执行,避免误删活跃 run 依赖的文件。
98+`bootstrap.sh` 负责创建这些目录,但不负责清理历史数据。
M docs/runtime/node-verification.md
+19, -153
  1@@ -1,89 +1,33 @@
  2 # node verification
  3 
  4-本页描述的是“节点上已经有真实进程时”的验证顺序。
  5+当前只检查 `mini`。
  6 
  7-它不负责加载服务,不会执行 `launchctl bootstrap`,也不会修改 Nginx / DNS。
  8-
  9-## 检查面
 10-
 11-`scripts/runtime/check-node.sh` 把节点验证拆成两层:
 12-
 13-| 层级 | 默认覆盖项 | 目的 |
 14-| --- | --- | --- |
 15-| 静态层 | runtime 目录、`dist/index.js`、安装副本 plist、共享 token、日志路径配置 | 确认 launchd 渲染结果和 repo/runtime 根目录一致 |
 16-| 运行态层 | 本地端口、conductor `/healthz` `/readyz` `/rolez`、status-api `/healthz` `/v1/status` `/v1/status/ui`、status-api 宿主进程、launchd 日志文件 | 确认节点上实际跑起来的进程与预期服务面一致 |
 17-
 18-默认检查集合是 `conductor + status-api`。如果节点暂时只跑 conductor,可以显式改成 `--service conductor`。如果还要把 `worker-runner` 也纳入同一次节点验证,再加 `--all-services`。
 19-
 20-默认 probe URL 仍假设 loopback:
 21-
 22-- conductor: `http://127.0.0.1:4317`
 23-- status-api: `http://127.0.0.1:4318`
 24-
 25-如果节点已经按 rollout 切到 Tailscale `100.x`,记得在下面命令里同步补上:
 26-
 27-- `--local-api-base http://100.x:4317`
 28-- `--status-api-base http://100.x:4318`
 29-
 30-下面的 `mini` / `mac` 示例按当前真实 rollout 直接写成 Tailscale `100.x` 版本;如果节点仍保持 loopback-only,把这些覆盖参数去掉即可。
 31-
 32-## 前置条件
 33-
 34-在进入 on-node 检查前,先确保这些步骤已经完成:
 35-
 36-1. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor`
 37-2. `cd /Users/george/code/baa-conductor && npx --yes pnpm -r build`
 38-3. `./scripts/runtime/install-launchd.sh ...` 已经渲染出目标节点的安装副本
 39-4. 节点上已经有真实进程
 40-
 41-第 4 步可以来自已有的 launchd 加载,也可以来自人工先行启动的进程;`check-node.sh` 只做验证,不负责启动。
 42-
 43-## mini 验证顺序
 44-
 45-### 1. 静态校验
 46+## 1. 构建与静态检查
 47 
 48 ```bash
 49-REPO_DIR=/Users/george/code/baa-conductor
 50-AGENTS_DIR="$HOME/Library/LaunchAgents"
 51-
 52+npx --yes pnpm -r build
 53 ./scripts/runtime/check-launchd.sh \
 54-  --repo-dir "$REPO_DIR" \
 55+  --repo-dir /Users/george/code/baa-conductor \
 56   --node mini \
 57-  --all-services \
 58-  --install-dir "$AGENTS_DIR" \
 59+  --service conductor \
 60+  --service status-api \
 61+  --install-dir /Users/george/Library/LaunchAgents \
 62+  --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
 63+  --control-api-base https://control-api.makefile.so \
 64   --local-api-base http://100.71.210.78:4317 \
 65   --local-api-allowed-hosts 100.71.210.78 \
 66   --status-api-host 100.71.210.78
 67 ```
 68 
 69-### 2. on-node 校验
 70-
 71-steady-state 下,`mini` 应该对外表现为 leader,因此 `rolez` 预期值写成 `leader`:
 72-
 73-```bash
 74-REPO_DIR=/Users/george/code/baa-conductor
 75-AGENTS_DIR="$HOME/Library/LaunchAgents"
 76-
 77-./scripts/runtime/check-node.sh \
 78-  --repo-dir "$REPO_DIR" \
 79-  --node mini \
 80-  --all-services \
 81-  --install-dir "$AGENTS_DIR" \
 82-  --local-api-base http://100.71.210.78:4317 \
 83-  --local-api-allowed-hosts 100.71.210.78 \
 84-  --status-api-base http://100.71.210.78:4318 \
 85-  --status-api-host 100.71.210.78 \
 86-  --expected-rolez leader
 87-```
 88-
 89-如果该节点已经是通过 launchd 常驻起来的,再加 `--check-loaded`,把 `launchctl print gui/<uid>/<label>` 也纳入检查,但这仍然只是读取状态,不会触发加载:
 90+## 2. 运行态检查
 91 
 92 ```bash
 93 ./scripts/runtime/check-node.sh \
 94-  --repo-dir "$REPO_DIR" \
 95+  --repo-dir /Users/george/code/baa-conductor \
 96   --node mini \
 97-  --all-services \
 98-  --install-dir "$AGENTS_DIR" \
 99+  --service conductor \
100+  --service status-api \
101+  --install-dir /Users/george/Library/LaunchAgents \
102   --local-api-base http://100.71.210.78:4317 \
103   --local-api-allowed-hosts 100.71.210.78 \
104   --status-api-base http://100.71.210.78:4318 \
105@@ -92,87 +36,9 @@ AGENTS_DIR="$HOME/Library/LaunchAgents"
106   --check-loaded
107 ```
108 
109-### 3. 失败时的人工 spot check
110-
111-```bash
112-LOCAL_API_BASE="${LOCAL_API_BASE:-http://100.71.210.78:4317}"
113-STATUS_API_BASE="${STATUS_API_BASE:-http://100.71.210.78:4318}"
114-
115-lsof -nP -iTCP:4317 -sTCP:LISTEN
116-lsof -nP -iTCP:4318 -sTCP:LISTEN
117-curl -sS "${LOCAL_API_BASE}/healthz"
118-curl -sS "${LOCAL_API_BASE}/readyz"
119-curl -sS "${LOCAL_API_BASE}/rolez"
120-curl -sS "${STATUS_API_BASE}/healthz"
121-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conductor.err.log
122-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-status-api.err.log
123-```
124-
125-## mac 验证顺序
126-
127-### 1. 静态校验
128-
129-```bash
130-REPO_DIR=/Users/george/code/baa-conductor
131-AGENTS_DIR="$HOME/Library/LaunchAgents"
132-
133-./scripts/runtime/check-launchd.sh \
134-  --repo-dir "$REPO_DIR" \
135-  --node mac \
136-  --all-services \
137-  --install-dir "$AGENTS_DIR" \
138-  --local-api-base http://100.112.239.13:4317 \
139-  --local-api-allowed-hosts 100.112.239.13 \
140-  --status-api-host 100.112.239.13
141-```
142-
143-### 2. on-node 校验
144-
145-steady-state 下,`mac` 应该保持 standby,因此默认把 `rolez` 预期值写成 `standby`:
146-
147-```bash
148-REPO_DIR=/Users/george/code/baa-conductor
149-AGENTS_DIR="$HOME/Library/LaunchAgents"
150-
151-./scripts/runtime/check-node.sh \
152-  --repo-dir "$REPO_DIR" \
153-  --node mac \
154-  --all-services \
155-  --install-dir "$AGENTS_DIR" \
156-  --local-api-base http://100.112.239.13:4317 \
157-  --local-api-allowed-hosts 100.112.239.13 \
158-  --status-api-base http://100.112.239.13:4318 \
159-  --status-api-host 100.112.239.13 \
160-  --expected-rolez standby
161-```
162-
163-如果这是 failover rehearsal 期间的 `mac`,只把 `--expected-rolez standby` 改成 `--expected-rolez leader`,其余步骤保持不变。
164-
165-### 3. `LaunchDaemons` 场景
166-
167-如果 `mac` 用的是 `/Library/LaunchDaemons`,把安装路径和域名一起改成 daemon 版本:
168-
169-```bash
170-sudo ./scripts/runtime/check-node.sh \
171-  --repo-dir /Users/george/code/baa-conductor \
172-  --node mac \
173-  --scope daemon \
174-  --install-dir /Library/LaunchDaemons \
175-  --username george \
176-  --local-api-base http://100.112.239.13:4317 \
177-  --local-api-allowed-hosts 100.112.239.13 \
178-  --status-api-base http://100.112.239.13:4318 \
179-  --status-api-host 100.112.239.13 \
180-  --expected-rolez standby \
181-  --check-loaded
182-```
183-
184-`--check-loaded` 在这里会去读 `launchctl print system/<label>`,不会重新 bootstrap 服务。
185-
186-## 常见失败信号
187+## 常见失败点
188 
189-- `conductor /readyz` 返回 `503`:节点进程存活,但 runtime 还没进入 ready 状态,先看 conductor stderr 日志。
190-- `conductor /rolez` 与预期不符:节点身份没问题,但当前 lease 角色与预期不一致,先确认是否处于 failover 或 standby 场景。
191-- `status-api` 端口没监听:`status-api` 宿主进程没有起来,或监听地址/端口与当前配置的 `127.0.0.1:4318` / `100.x:4318` 不一致。
192-- `status-api` 进程匹配失败:节点上可能跑的是旧路径、旧 worktree,或 launchd 仍指向错误的 `dist/index.js`。
193-- launchd 日志文件缺失:安装副本路径虽然存在,但服务尚未真正由 launchd 打开过对应 `StandardOutPath` / `StandardErrorPath`。
194+- `conductor /rolez` 不是 `leader`
195+- `status-api /v1/status` 与 control-api 不一致
196+- `launchctl print` 失败
197+- `logs/launchd/*.log` 没有新内容
D docs/runtime/real-rollout-2026-03-22.md
+0, -75
 1@@ -1,75 +0,0 @@
 2-# 2026-03-22 Runtime Rollout Record
 3-
 4-## Nodes
 5-
 6-| Node | Host | Repo path used for rollout | Result |
 7-| --- | --- | --- | --- |
 8-| `mini` | local machine `Mac`, Tailscale `100.71.210.78` | `/Users/george/code/baa-conductor-T028-v2` | launchd reloaded, on-node checks passed, `rolez=leader` |
 9-| `mac` | remote `MacBookPro`, Tailscale `100.112.239.13` | `/Users/george/code/baa-conductor-T028-v2` | repo synced, launchd reloaded, on-node checks passed, `rolez=standby` |
10-
11-## Shared runtime inputs
12-
13-- repo path used for this rollout: `/Users/george/code/baa-conductor-T028-v2`
14-- launchd install dir: `/Users/george/Library/LaunchAgents`
15-- shared token source: `/Users/george/.config/baa-conductor/control-api-worker.secrets.env`
16-- control API base: `https://control-api.makefile.so`
17-
18-## mini
19-
20-Executed locally on 2026-03-22 CST:
21-
22-1. `npx --yes pnpm -r build`
23-2. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
24-3. `./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
25-4. `./scripts/runtime/check-launchd.sh ...`
26-5. `./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
27-6. `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
28-
29-Observed state after reload:
30-
31-- conductor PID: `79697`
32-- status-api PID: `79700`
33-- `http://100.71.210.78:4317/healthz -> ok`
34-- `http://100.71.210.78:4317/rolez -> leader`
35-- `http://100.71.210.78:4318/healthz -> ok`
36-
37-## mac
38-
39-Executed remotely over `ssh george@100.112.239.13` on 2026-03-22 CST:
40-
41-1. `rsync -az --delete --exclude '.git' --exclude 'node_modules' /Users/george/code/baa-conductor-T028-v2/ george@100.112.239.13:/Users/george/code/baa-conductor-T028-v2/`
42-2. `cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
43-3. `cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
44-4. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
45-5. `./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
46-6. `./scripts/runtime/check-launchd.sh ...`
47-7. `./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
48-8. `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded`
49-
50-Observed state after reload:
51-
52-- conductor PID: `60338`
53-- status-api PID: `60341`
54-- `http://100.112.239.13:4317/healthz -> ok`
55-- `http://100.112.239.13:4317/rolez -> standby`
56-- `http://100.112.239.13:4318/healthz -> ok`
57-
58-## Cross-node reachability
59-
60-Validated after both nodes switched to Tailscale listeners:
61-
62-- local host:
63-  - `curl http://100.71.210.78:4317/healthz -> ok`
64-  - `curl http://100.71.210.78:4318/healthz -> ok`
65-  - `curl http://100.112.239.13:4317/healthz -> ok`
66-  - `curl http://100.112.239.13:4318/healthz -> ok`
67-- VPS `root@192.210.137.113 -p 2222`:
68-  - `curl http://100.71.210.78:4317/healthz -> ok`
69-  - `curl http://100.112.239.13:4317/healthz -> ok`
70-  - `curl http://100.71.210.78:4318/healthz -> ok`
71-  - `curl http://100.112.239.13:4318/healthz -> ok`
72-
73-## Notes
74-
75-- This rollout replaced the old `/Users/george/code/baa-conductor-T028` runtime path with `/Users/george/code/baa-conductor-T028-v2` on both nodes.
76-- `check-node.sh` now forwards `--local-api-allowed-hosts` and `--status-api-host` to `check-launchd.sh`; without that fix, Tailscale rollout checks would fail even though the installed plist values were correct.
M ops/launchd/so.makefile.baa-conductor.plist
+2, -2
 1@@ -4,8 +4,8 @@
 2   Source template kept in the repo.
 3   Default values target the mini node at /Users/george/code/baa-conductor.
 4   Use scripts/runtime/install-launchd.sh to render the actual install copy.
 5-  For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID,
 6-  BAA_SHARED_TOKEN, and keep the CLI args aligned with those values.
 7+  Adjust BAA_SHARED_TOKEN and the listen-related variables before loading if
 8+  the mini node uses a non-default address.
 9   If this file is installed under /Library/LaunchDaemons, add UserName and keep
10   every path absolute; launchd will not read shell rc files for you.
11 -->
M ops/launchd/so.makefile.baa-worker-runner.plist
+2, -2
 1@@ -4,8 +4,8 @@
 2   Source template kept in the repo.
 3   Defaults target the mini node and share the same runtime tree as conductor.
 4   Use scripts/runtime/install-launchd.sh to render the actual install copy.
 5-  For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID, and
 6-  BAA_SHARED_TOKEN before copying the file into the launchd install path.
 7+  Adjust BAA_SHARED_TOKEN and runtime paths before copying the file into the
 8+  launchd install path.
 9 -->
10 <plist version="1.0">
11   <dict>
M ops/nginx/baa-conductor.conf
+5, -64
 1@@ -1,15 +1,11 @@
 2 # 部署目标:
 3 # - /etc/nginx/sites-available/baa-conductor.conf
 4 # - /etc/nginx/sites-enabled/baa-conductor.conf -> symlink to sites-available
 5-# - /etc/nginx/includes/baa-conductor/*.conf 由仓库里的 ops/nginx/includes/* 同步过去
 6 #
 7-# 说明:
 8-# - conductor.makefile.so 作为统一入口,走 mini 主、mac 备的 upstream
 9-# - mini-conductor.makefile.so 与 mac-conductor.makefile.so 直连单节点 upstream
10-# - 所有 upstream 都直接写 Tailscale 100.x 地址
11-# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
12-# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
13-# - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
14+# 当前主线只保留一个公网入口:
15+# - conductor.makefile.so -> VPS -> mini 100.71.210.78:4317
16+# - 不依赖 MagicDNS
17+# - 证书路径使用 Let's Encrypt 默认目录,若走其他证书方案请替换
18 
19 map $http_upgrade $connection_upgrade {
20     default upgrade;
21@@ -17,27 +13,14 @@ map $http_upgrade $connection_upgrade {
22 }
23 
24 upstream conductor_primary {
25-    # mini 主节点,使用 Tailscale IPv4 私网地址回源
26     server 100.71.210.78:4317 max_fails=2 fail_timeout=5s;
27-    # mac 备用节点,使用 Tailscale IPv4 私网地址回源
28-    server 100.112.239.13:4317 backup;
29     keepalive 32;
30 }
31 
32-upstream mini_conductor_direct {
33-    server 100.71.210.78:4317;
34-    keepalive 16;
35-}
36-
37-upstream mac_conductor_direct {
38-    server 100.112.239.13:4317;
39-    keepalive 16;
40-}
41-
42 server {
43     listen 80;
44     listen [::]:80;
45-    server_name conductor.makefile.so mini-conductor.makefile.so mac-conductor.makefile.so;
46+    server_name conductor.makefile.so;
47 
48     return 301 https://$host$request_uri;
49 }
50@@ -76,45 +59,3 @@ server {
51         include /etc/nginx/includes/baa-conductor/common-proxy.conf;
52     }
53 }
54-
55-server {
56-    listen 443 ssl http2;
57-    listen [::]:443 ssl http2;
58-    server_name mini-conductor.makefile.so;
59-
60-    ssl_certificate     /etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem;
61-    ssl_certificate_key /etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem;
62-    ssl_protocols       TLSv1.2 TLSv1.3;
63-    ssl_session_cache   shared:BAAConductorTLS:10m;
64-    ssl_session_timeout 1d;
65-
66-    access_log /var/log/nginx/baa-conductor-mini.access.log;
67-    error_log  /var/log/nginx/baa-conductor-mini.error.log warn;
68-
69-    location / {
70-        include /etc/nginx/includes/baa-conductor/direct-node-auth.conf;
71-        proxy_pass http://mini_conductor_direct;
72-        include /etc/nginx/includes/baa-conductor/common-proxy.conf;
73-    }
74-}
75-
76-server {
77-    listen 443 ssl http2;
78-    listen [::]:443 ssl http2;
79-    server_name mac-conductor.makefile.so;
80-
81-    ssl_certificate     /etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem;
82-    ssl_certificate_key /etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem;
83-    ssl_protocols       TLSv1.2 TLSv1.3;
84-    ssl_session_cache   shared:BAAConductorTLS:10m;
85-    ssl_session_timeout 1d;
86-
87-    access_log /var/log/nginx/baa-conductor-mac.access.log;
88-    error_log  /var/log/nginx/baa-conductor-mac.error.log warn;
89-
90-    location / {
91-        include /etc/nginx/includes/baa-conductor/direct-node-auth.conf;
92-        proxy_pass http://mac_conductor_direct;
93-        include /etc/nginx/includes/baa-conductor/common-proxy.conf;
94-    }
95-}
D ops/nginx/includes/direct-node-auth.conf
+0, -10
 1@@ -1,10 +0,0 @@
 2-# 直连 mini/mac 的调试入口默认启用 Basic Auth。
 3-# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
 4-# satisfy any;
 5-# allow 203.0.113.10;
 6-# allow 2001:db8::/48;
 7-# deny all;
 8-# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
 9-
10-auth_basic "baa-conductor direct node";
11-auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor;
M ops/nginx/templates/baa-conductor.conf.template
+3, -66
 1@@ -1,14 +1,6 @@
 2-# 部署目标:
 3-# - __NGINX_SITE_INSTALL_PATH__
 4-# - __NGINX_SITE_ENABLED_PATH__ -> symlink to sites-available
 5-# - __NGINX_INCLUDE_GLOB__ 由仓库里的 ops/nginx/includes/* 同步过去
 6-#
 7-# 说明:
 8-# - __CONDUCTOR_HOST__ 作为统一入口,走 mini 主、mac 备的 upstream
 9-# - __MINI_DIRECT_HOST__ 与 __MAC_DIRECT_HOST__ 直连单节点 upstream
10+# - __CONDUCTOR_HOST__ 作为唯一公网入口
11 # - 所有 upstream 都直接写 Tailscale 100.x 地址
12-# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
13-# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
14+# - 不使用 MagicDNS 名称
15 # - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
16 
17 map $http_upgrade $connection_upgrade {
18@@ -17,27 +9,14 @@ map $http_upgrade $connection_upgrade {
19 }
20 
21 upstream conductor_primary {
22-    # mini 主节点,使用 Tailscale IPv4 私网地址回源
23     server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__ max_fails=2 fail_timeout=5s;
24-    # mac 备用节点,使用 Tailscale IPv4 私网地址回源
25-    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__ backup;
26     keepalive 32;
27 }
28 
29-upstream mini_conductor_direct {
30-    server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__;
31-    keepalive 16;
32-}
33-
34-upstream mac_conductor_direct {
35-    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__;
36-    keepalive 16;
37-}
38-
39 server {
40     listen 80;
41     listen [::]:80;
42-    server_name __CONDUCTOR_HOST__ __MINI_DIRECT_HOST__ __MAC_DIRECT_HOST__;
43+    server_name __CONDUCTOR_HOST__;
44 
45     return 301 https://$host$request_uri;
46 }
47@@ -76,45 +55,3 @@ server {
48         include __NGINX_INCLUDE_DIR__/common-proxy.conf;
49     }
50 }
51-
52-server {
53-    listen 443 ssl http2;
54-    listen [::]:443 ssl http2;
55-    server_name __MINI_DIRECT_HOST__;
56-
57-    ssl_certificate     __MINI_CERT_FULLCHAIN__;
58-    ssl_certificate_key __MINI_CERT_KEY__;
59-    ssl_protocols       TLSv1.2 TLSv1.3;
60-    ssl_session_cache   shared:BAAConductorTLS:10m;
61-    ssl_session_timeout 1d;
62-
63-    access_log /var/log/nginx/baa-conductor-mini.access.log;
64-    error_log  /var/log/nginx/baa-conductor-mini.error.log warn;
65-
66-    location / {
67-        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
68-        proxy_pass http://mini_conductor_direct;
69-        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
70-    }
71-}
72-
73-server {
74-    listen 443 ssl http2;
75-    listen [::]:443 ssl http2;
76-    server_name __MAC_DIRECT_HOST__;
77-
78-    ssl_certificate     __MAC_CERT_FULLCHAIN__;
79-    ssl_certificate_key __MAC_CERT_KEY__;
80-    ssl_protocols       TLSv1.2 TLSv1.3;
81-    ssl_session_cache   shared:BAAConductorTLS:10m;
82-    ssl_session_timeout 1d;
83-
84-    access_log /var/log/nginx/baa-conductor-mac.access.log;
85-    error_log  /var/log/nginx/baa-conductor-mac.error.log warn;
86-
87-    location / {
88-        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
89-        proxy_pass http://mac_conductor_direct;
90-        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
91-    }
92-}
D ops/nginx/templates/includes/direct-node-auth.conf.template
+0, -10
 1@@ -1,10 +0,0 @@
 2-# 直连 mini/mac 的调试入口默认启用 Basic Auth。
 3-# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
 4-# satisfy any;
 5-# allow 203.0.113.10;
 6-# allow 2001:db8::/48;
 7-# deny all;
 8-# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
 9-
10-auth_basic "baa-conductor direct node";
11-auth_basic_user_file __NGINX_HTPASSWD_PATH__;
D scripts/failover/common.sh
+0, -176
  1@@ -1,176 +0,0 @@
  2-#!/usr/bin/env bash
  3-
  4-if [[ -n "${BAA_FAILOVER_COMMON_SH_LOADED:-}" ]]; then
  5-  return 0
  6-fi
  7-
  8-readonly BAA_FAILOVER_COMMON_SH_LOADED=1
  9-readonly BAA_FAILOVER_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 10-readonly BAA_FAILOVER_REPO_DIR_DEFAULT="$(cd -- "${BAA_FAILOVER_SCRIPT_DIR}/../.." && pwd)"
 11-readonly BAA_FAILOVER_DEFAULT_ENV_PATH="${BAA_FAILOVER_REPO_DIR_DEFAULT}/scripts/ops/baa-conductor.env.example"
 12-readonly BAA_FAILOVER_DEFAULT_CONTROL_API_BASE="https://control-api.makefile.so"
 13-
 14-failover_log() {
 15-  printf '[failover] %s\n' "$*"
 16-}
 17-
 18-failover_warn() {
 19-  printf '[failover] warning: %s\n' "$*" >&2
 20-}
 21-
 22-failover_error() {
 23-  printf '[failover] error: %s\n' "$*" >&2
 24-}
 25-
 26-die() {
 27-  failover_error "$*"
 28-  exit 1
 29-}
 30-
 31-require_command() {
 32-  if ! command -v "$1" >/dev/null 2>&1; then
 33-    die "Missing required command: $1"
 34-  fi
 35-}
 36-
 37-contains_value() {
 38-  local needle="$1"
 39-  shift
 40-
 41-  local value
 42-  for value in "$@"; do
 43-    if [[ "$value" == "$needle" ]]; then
 44-      return 0
 45-    fi
 46-  done
 47-
 48-  return 1
 49-}
 50-
 51-validate_node() {
 52-  case "$1" in
 53-    mini | mac) ;;
 54-    *)
 55-      die "Unsupported node: $1"
 56-      ;;
 57-  esac
 58-}
 59-
 60-validate_scenario() {
 61-  case "$1" in
 62-    planned | emergency | switchback) ;;
 63-    *)
 64-      die "Unsupported scenario: $1"
 65-      ;;
 66-  esac
 67-}
 68-
 69-shell_quote() {
 70-  printf '%q' "$1"
 71-}
 72-
 73-require_value() {
 74-  local key="$1"
 75-  local value="${!key:-}"
 76-
 77-  if [[ -z "$value" ]]; then
 78-    die "Missing required value in inventory: ${key}"
 79-  fi
 80-
 81-  printf '%s\n' "$value"
 82-}
 83-
 84-validate_tailscale_ipv4() {
 85-  local value="$1"
 86-  local key="$2"
 87-
 88-  if [[ ! "$value" =~ ^100\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]]; then
 89-    die "${key} must be a Tailscale 100.x IPv4 address: ${value}"
 90-  fi
 91-}
 92-
 93-load_env_file() {
 94-  local env_path="$1"
 95-
 96-  if [[ ! -f "$env_path" ]]; then
 97-    die "Inventory file not found: ${env_path}"
 98-  fi
 99-
100-  set -a
101-  # shellcheck disable=SC1090
102-  source "$env_path"
103-  set +a
104-}
105-
106-load_inventory() {
107-  local env_path="$1"
108-
109-  load_env_file "$env_path"
110-
111-  FAILOVER_ENV_PATH="$env_path"
112-  FAILOVER_APP_NAME="${BAA_APP_NAME:-baa-conductor}"
113-  FAILOVER_PUBLIC_IPV4="${BAA_PUBLIC_IPV4:-}"
114-  FAILOVER_PUBLIC_IPV6="${BAA_PUBLIC_IPV6:-}"
115-  FAILOVER_CONDUCTOR_HOST="$(require_value BAA_CONDUCTOR_HOST)"
116-  FAILOVER_MINI_DIRECT_HOST="$(require_value BAA_MINI_DIRECT_HOST)"
117-  FAILOVER_MAC_DIRECT_HOST="$(require_value BAA_MAC_DIRECT_HOST)"
118-  FAILOVER_MINI_TAILSCALE_IP="$(require_value BAA_MINI_TAILSCALE_IP)"
119-  FAILOVER_MAC_TAILSCALE_IP="$(require_value BAA_MAC_TAILSCALE_IP)"
120-  FAILOVER_CONDUCTOR_PORT="${BAA_CONDUCTOR_PORT:-4317}"
121-  FAILOVER_CONTROL_API_BASE="${BAA_CONTROL_API_BASE:-$BAA_FAILOVER_DEFAULT_CONTROL_API_BASE}"
122-
123-  validate_tailscale_ipv4 "$FAILOVER_MINI_TAILSCALE_IP" "BAA_MINI_TAILSCALE_IP"
124-  validate_tailscale_ipv4 "$FAILOVER_MAC_TAILSCALE_IP" "BAA_MAC_TAILSCALE_IP"
125-}
126-
127-node_direct_host() {
128-  validate_node "$1"
129-
130-  case "$1" in
131-    mini)
132-      printf '%s\n' "$FAILOVER_MINI_DIRECT_HOST"
133-      ;;
134-    mac)
135-      printf '%s\n' "$FAILOVER_MAC_DIRECT_HOST"
136-      ;;
137-  esac
138-}
139-
140-node_tailscale_ip() {
141-  validate_node "$1"
142-
143-  case "$1" in
144-    mini)
145-      printf '%s\n' "$FAILOVER_MINI_TAILSCALE_IP"
146-      ;;
147-    mac)
148-      printf '%s\n' "$FAILOVER_MAC_TAILSCALE_IP"
149-      ;;
150-  esac
151-}
152-
153-node_default_role() {
154-  validate_node "$1"
155-
156-  case "$1" in
157-    mini)
158-      printf '%s\n' "primary"
159-      ;;
160-    mac)
161-      printf '%s\n' "standby"
162-      ;;
163-  esac
164-}
165-
166-node_default_id() {
167-  validate_node "$1"
168-
169-  case "$1" in
170-    mini)
171-      printf '%s\n' "mini-main"
172-      ;;
173-    mac)
174-      printf '%s\n' "mac-standby"
175-      ;;
176-  esac
177-}
D scripts/failover/print-checklist.sh
+0, -240
  1@@ -1,240 +0,0 @@
  2-#!/usr/bin/env bash
  3-set -euo pipefail
  4-
  5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  6-# shellcheck source=./common.sh
  7-source "${SCRIPT_DIR}/common.sh"
  8-
  9-usage() {
 10-  cat <<'EOF'
 11-Usage:
 12-  scripts/failover/print-checklist.sh --scenario planned|emergency|switchback [options]
 13-
 14-Options:
 15-  --scenario NAME         planned, emergency, or switchback.
 16-  --env PATH              Inventory file to load.
 17-  --control-api-base URL  Override the control API base URL.
 18-  --help                  Show this help text.
 19-EOF
 20-}
 21-
 22-print_common_exports() {
 23-  local env_q=""
 24-  local control_api_q=""
 25-
 26-  env_q="$(shell_quote "$FAILOVER_ENV_PATH")"
 27-  control_api_q="$(shell_quote "$control_api_base")"
 28-
 29-  cat <<EOF
 30-Suggested operator variables
 31-----------------------------
 32-export FAILOVER_ENV=${env_q}
 33-export DIRECT_BASIC_AUTH='conductor-ops:REPLACE_ME'
 34-export READONLY_TOKEN='REPLACE_ME'
 35-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
 36-export MINI_SSH='<mini-admin-shell>'
 37-export MAC_SSH='<mac-admin-shell>'
 38-export VPS_SSH='root@<vps>'
 39-export CONTROL_API_BASE=${control_api_q}
 40-
 41-Common baseline commands
 42-------------------------
 43-./scripts/failover/print-topology.sh --env "\$FAILOVER_ENV"
 44-./scripts/failover/rehearsal-check.sh \\
 45-  --env "\$FAILOVER_ENV" \\
 46-  --basic-auth "\$DIRECT_BASIC_AUTH" \\
 47-  --bearer-token "\$READONLY_TOKEN" \\
 48-  --control-api-base "\$CONTROL_API_BASE"
 49-EOF
 50-}
 51-
 52-print_planned_checklist() {
 53-  cat <<'EOF'
 54-
 55-Planned failover checklist
 56---------------------------
 57-1. Confirm the baseline is mini leader:
 58-./scripts/failover/rehearsal-check.sh \
 59-  --env "$FAILOVER_ENV" \
 60-  --basic-auth "$DIRECT_BASIC_AUTH" \
 61-  --bearer-token "$READONLY_TOKEN" \
 62-  --control-api-base "$CONTROL_API_BASE" \
 63-  --expect-leader mini
 64-
 65-2. Drain and then pause automation:
 66-curl -sS -X POST \
 67-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 68-  -H 'Content-Type: application/json' \
 69-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_rehearsal"}' \
 70-  "${CONTROL_API_BASE%/}/v1/system/drain"
 71-
 72-curl -sS -X POST \
 73-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 74-  -H 'Content-Type: application/json' \
 75-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_cutover"}' \
 76-  "${CONTROL_API_BASE%/}/v1/system/pause"
 77-
 78-3. On mac, confirm launchd has a healthy standby install:
 79-ssh "$MAC_SSH" \
 80-  'cd /Users/george/code/baa-conductor && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac --install-dir "$HOME/Library/LaunchAgents"'
 81-
 82-4. On mini, stop only the conductor service so VPS Nginx falls through to mac:
 83-ssh "$MINI_SSH" \
 84-  'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
 85-
 86-5. Verify mac is now leader and public ingress still returns leader:
 87-./scripts/failover/rehearsal-check.sh \
 88-  --env "$FAILOVER_ENV" \
 89-  --basic-auth "$DIRECT_BASIC_AUTH" \
 90-  --bearer-token "$READONLY_TOKEN" \
 91-  --control-api-base "$CONTROL_API_BASE" \
 92-  --skip-node mini \
 93-  --expect-leader mac
 94-
 95-6. Resume automation only after mac direct host and public host are both healthy:
 96-curl -sS -X POST \
 97-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
 98-  -H 'Content-Type: application/json' \
 99-  -d '{"requested_by":"ops_runbook","reason":"planned_failover_complete"}' \
100-  "${CONTROL_API_BASE%/}/v1/system/resume"
101-EOF
102-}
103-
104-print_emergency_checklist() {
105-  cat <<'EOF'
106-
107-Emergency failover checklist
108-----------------------------
109-1. Snapshot public state first. If mini is already gone, skip its direct checks:
110-./scripts/failover/rehearsal-check.sh \
111-  --env "$FAILOVER_ENV" \
112-  --basic-auth "$DIRECT_BASIC_AUTH" \
113-  --bearer-token "$READONLY_TOKEN" \
114-  --control-api-base "$CONTROL_API_BASE" \
115-  --skip-node mini \
116-  --expect-leader mac
117-
118-2. On mac, repair or restart the local conductor service if needed:
119-ssh "$MAC_SSH" \
120-  'cd /Users/george/code/baa-conductor && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac --install-dir "$HOME/Library/LaunchAgents"'
121-
122-ssh "$MAC_SSH" \
123-  'cd /Users/george/code/baa-conductor && ./scripts/runtime/reload-launchd.sh'
124-
125-3. If public ingress still lands on mini while mini is reachable but no longer leader, hotfix the VPS config so mac becomes the first upstream:
126-ssh "$VPS_SSH" 'sudo cp /etc/nginx/sites-available/baa-conductor.conf /etc/nginx/sites-available/baa-conductor.conf.bak.$(date +%Y%m%d%H%M%S)'
127-ssh "$VPS_SSH" 'sudo editor /etc/nginx/sites-available/baa-conductor.conf'
128-ssh "$VPS_SSH" 'sudo nginx -t && sudo systemctl reload nginx'
129-
130-4. Re-run the snapshot until public /rolez=leader and mac direct /rolez=leader:
131-./scripts/failover/rehearsal-check.sh \
132-  --env "$FAILOVER_ENV" \
133-  --basic-auth "$DIRECT_BASIC_AUTH" \
134-  --bearer-token "$READONLY_TOKEN" \
135-  --control-api-base "$CONTROL_API_BASE" \
136-  --skip-node mini \
137-  --expect-leader mac
138-
139-5. Record whether the VPS carried an emergency Nginx hotfix. Switchback must restore the canonical repo-rendered bundle later.
140-EOF
141-}
142-
143-print_switchback_checklist() {
144-  cat <<'EOF'
145-
146-Switchback checklist
147---------------------
148-1. Rebuild and validate mini before touching traffic:
149-ssh "$MINI_SSH" \
150-  'cd /Users/george/code/baa-conductor && npx --yes pnpm -r build && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini --install-dir "$HOME/Library/LaunchAgents"'
151-
152-2. Pause automation so lease ownership can move cleanly back to mini:
153-curl -sS -X POST \
154-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
155-  -H 'Content-Type: application/json' \
156-  -d '{"requested_by":"ops_runbook","reason":"switchback_prepare"}' \
157-  "${CONTROL_API_BASE%/}/v1/system/pause"
158-
159-3. Stop mac conductor and restart mini conductor:
160-ssh "$MAC_SSH" \
161-  'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
162-
163-ssh "$MINI_SSH" \
164-  'cd /Users/george/code/baa-conductor && ./scripts/runtime/reload-launchd.sh'
165-
166-4. If emergency hotfixes changed the deployed VPS config, restore the canonical mini-primary bundle from the repo:
167-scripts/ops/nginx-sync-plan.sh --env "$FAILOVER_ENV" --bundle-dir .tmp/ops/baa-conductor-nginx
168-rsync -av .tmp/ops/baa-conductor-nginx/ "$VPS_SSH":/tmp/baa-conductor-nginx/
169-ssh "$VPS_SSH" 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
170-
171-5. Verify leadership moved back to mini:
172-./scripts/failover/rehearsal-check.sh \
173-  --env "$FAILOVER_ENV" \
174-  --basic-auth "$DIRECT_BASIC_AUTH" \
175-  --bearer-token "$READONLY_TOKEN" \
176-  --control-api-base "$CONTROL_API_BASE" \
177-  --skip-node mac \
178-  --expect-leader mini
179-
180-6. Resume automation after public and mini direct hosts are both healthy:
181-curl -sS -X POST \
182-  -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
183-  -H 'Content-Type: application/json' \
184-  -d '{"requested_by":"ops_runbook","reason":"switchback_complete"}' \
185-  "${CONTROL_API_BASE%/}/v1/system/resume"
186-EOF
187-}
188-
189-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
190-scenario=""
191-control_api_base=""
192-
193-while [[ $# -gt 0 ]]; do
194-  case "$1" in
195-    --scenario)
196-      validate_scenario "$2"
197-      scenario="$2"
198-      shift 2
199-      ;;
200-    --env)
201-      env_path="$2"
202-      shift 2
203-      ;;
204-    --control-api-base)
205-      control_api_base="$2"
206-      shift 2
207-      ;;
208-    --help)
209-      usage
210-      exit 0
211-      ;;
212-    *)
213-      die "Unknown option: $1"
214-      ;;
215-  esac
216-done
217-
218-if [[ -z "$scenario" ]]; then
219-  die "--scenario is required"
220-fi
221-
222-load_inventory "$env_path"
223-
224-if [[ -z "$control_api_base" ]]; then
225-  control_api_base="$FAILOVER_CONTROL_API_BASE"
226-fi
227-
228-printf 'Scenario: %s\n' "$scenario"
229-print_common_exports
230-
231-case "$scenario" in
232-  planned)
233-    print_planned_checklist
234-    ;;
235-  emergency)
236-    print_emergency_checklist
237-    ;;
238-  switchback)
239-    print_switchback_checklist
240-    ;;
241-esac
D scripts/failover/print-topology.sh
+0, -74
 1@@ -1,74 +0,0 @@
 2-#!/usr/bin/env bash
 3-set -euo pipefail
 4-
 5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 6-# shellcheck source=./common.sh
 7-source "${SCRIPT_DIR}/common.sh"
 8-
 9-usage() {
10-  cat <<'EOF'
11-Usage:
12-  scripts/failover/print-topology.sh [options]
13-
14-Options:
15-  --env PATH   Inventory file to load. Defaults to scripts/ops/baa-conductor.env.example.
16-  --help       Show this help text.
17-EOF
18-}
19-
20-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
21-
22-while [[ $# -gt 0 ]]; do
23-  case "$1" in
24-    --env)
25-      env_path="$2"
26-      shift 2
27-      ;;
28-    --help)
29-      usage
30-      exit 0
31-      ;;
32-    *)
33-      die "Unknown option: $1"
34-      ;;
35-  esac
36-done
37-
38-load_inventory "$env_path"
39-
40-public_targets="${FAILOVER_PUBLIC_IPV4:-<unset>}"
41-if [[ -n "${FAILOVER_PUBLIC_IPV6:-}" ]]; then
42-  public_targets="${public_targets}, ${FAILOVER_PUBLIC_IPV6}"
43-fi
44-
45-cat <<EOF
46-Failover Topology
47-=================
48-
49-Inventory: ${FAILOVER_ENV_PATH}
50-Control API: ${FAILOVER_CONTROL_API_BASE}
51-
52-Public ingress
53---------------
54-- Cloudflare DNS keeps conductor hosts pinned to the VPS public address: ${public_targets}
55-- https://${FAILOVER_CONDUCTOR_HOST} -> VPS Nginx upstream conductor_primary
56-- conductor_primary -> mini ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} (primary), mac ${FAILOVER_MAC_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} (backup)
57-
58-Direct node hosts
59------------------
60-- https://${FAILOVER_MINI_DIRECT_HOST} -> Basic Auth -> mini ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT}
61-- https://${FAILOVER_MAC_DIRECT_HOST} -> Basic Auth -> mac ${FAILOVER_MAC_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT}
62-
63-launchd defaults
64-----------------
65-- mini: BAA_CONDUCTOR_HOST=mini, BAA_CONDUCTOR_ROLE=primary, BAA_NODE_ID=mini-main
66-- mac: BAA_CONDUCTOR_HOST=mac, BAA_CONDUCTOR_ROLE=standby, BAA_NODE_ID=mac-standby
67-- Both nodes keep the same repo/runtime root: /Users/george/code/baa-conductor
68-
69-Operational notes
70------------------
71-- Cloudflare DNS is not part of failover or switchback. Public traffic stays on the VPS.
72-- Nginx failover is transport-based only. It reacts when a 100.x upstream stops accepting traffic.
73-- Nginx does not inspect leader lease state. If mini still answers on ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} but /rolez says standby, public ingress can still land on mini until mini is stopped or the VPS config is hotfixed.
74-- launchd decides whether each node keeps serving 127.0.0.1:4317 and its Tailscale listener. Control API is only for drain/pause/resume and lease observation.
75-EOF
D scripts/failover/rehearsal-check.sh
+0, -384
  1@@ -1,384 +0,0 @@
  2-#!/usr/bin/env bash
  3-set -euo pipefail
  4-
  5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  6-# shellcheck source=./common.sh
  7-source "${SCRIPT_DIR}/common.sh"
  8-
  9-usage() {
 10-  cat <<'EOF'
 11-Usage:
 12-  scripts/failover/rehearsal-check.sh [options]
 13-
 14-Options:
 15-  --env PATH                 Inventory file to load.
 16-  --basic-auth USER:PASS     Basic Auth for mini/mac direct domains.
 17-  --bearer-token TOKEN       Bearer token for GET /v1/system/state.
 18-  --bearer-token-file PATH   Read the bearer token from a file.
 19-  --control-api-base URL     Override the control API base URL.
 20-  --expect-leader NODE       Assert that mini or mac is the active leader.
 21-  --skip-node NODE           Skip direct checks for one node. Repeatable.
 22-  --skip-public              Skip public conductor host checks.
 23-  --skip-control-api         Skip GET /v1/system/state even when a token is available.
 24-  --timeout SEC              Per-request curl timeout. Defaults to 5.
 25-  --help                     Show this help text.
 26-
 27-Notes:
 28-  - Public and direct probes are read-only GET requests against /healthz, /readyz, and /rolez.
 29-  - Direct-node checks are skipped automatically when Basic Auth is not provided.
 30-  - Control API checks are skipped automatically when a bearer token is not provided.
 31-EOF
 32-}
 33-
 34-require_command curl
 35-require_command node
 36-
 37-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
 38-basic_auth="${BAA_FAILOVER_BASIC_AUTH:-}"
 39-bearer_token="${BAA_CONTROL_API_TOKEN:-}"
 40-bearer_token_file=""
 41-control_api_base=""
 42-expect_leader=""
 43-timeout_sec="5"
 44-skip_public="0"
 45-skip_control_api="0"
 46-skip_nodes=()
 47-failures=0
 48-
 49-record_failure() {
 50-  failover_error "$*"
 51-  failures=$((failures + 1))
 52-}
 53-
 54-probe_endpoint() {
 55-  local status_var="$1"
 56-  local body_var="$2"
 57-  local error_var="$3"
 58-  local url="$4"
 59-  shift 4
 60-
 61-  local tmp_body=""
 62-  local tmp_err=""
 63-  local http_code=""
 64-  local error_message=""
 65-  local body=""
 66-
 67-  tmp_body="$(mktemp)"
 68-  tmp_err="$(mktemp)"
 69-
 70-  if ! http_code="$(curl -sS -L --max-time "$timeout_sec" -o "$tmp_body" -w '%{http_code}' "$@" "$url" 2>"$tmp_err")"; then
 71-    error_message="$(tr '\n' ' ' < "$tmp_err")"
 72-    error_message="${error_message%" "}"
 73-    printf -v "$status_var" '%s' "curl_error"
 74-    printf -v "$body_var" '%s' ""
 75-    printf -v "$error_var" '%s' "$error_message"
 76-    rm -f "$tmp_body" "$tmp_err"
 77-    return 1
 78-  fi
 79-
 80-  body="$(tr -d '\r' < "$tmp_body")"
 81-  body="${body%$'\n'}"
 82-
 83-  printf -v "$status_var" '%s' "$http_code"
 84-  printf -v "$body_var" '%s' "$body"
 85-  printf -v "$error_var" '%s' ""
 86-
 87-  rm -f "$tmp_body" "$tmp_err"
 88-}
 89-
 90-format_probe_result() {
 91-  local status="$1"
 92-  local body="$2"
 93-  local error="$3"
 94-
 95-  if [[ "$status" == "curl_error" ]]; then
 96-    if [[ -n "$error" ]]; then
 97-      printf 'ERROR(%s)' "$error"
 98-    else
 99-      printf 'ERROR'
100-    fi
101-    return 0
102-  fi
103-
104-  if [[ -z "$body" ]]; then
105-    printf '%s(<empty>)' "$status"
106-    return 0
107-  fi
108-
109-  printf '%s(%s)' "$status" "$body"
110-}
111-
112-probe_surface() {
113-  local label="$1"
114-  local base_url="$2"
115-  shift 2
116-
117-  probe_endpoint "${label}_health_status" "${label}_health_body" "${label}_health_error" "${base_url}/healthz" "$@" || true
118-  probe_endpoint "${label}_ready_status" "${label}_ready_body" "${label}_ready_error" "${base_url}/readyz" "$@" || true
119-  probe_endpoint "${label}_role_status" "${label}_role_body" "${label}_role_error" "${base_url}/rolez" "$@" || true
120-}
121-
122-print_surface_summary() {
123-  local label="$1"
124-  local base_url="$2"
125-  local health_status_var="${label}_health_status"
126-  local health_body_var="${label}_health_body"
127-  local health_error_var="${label}_health_error"
128-  local ready_status_var="${label}_ready_status"
129-  local ready_body_var="${label}_ready_body"
130-  local ready_error_var="${label}_ready_error"
131-  local role_status_var="${label}_role_status"
132-  local role_body_var="${label}_role_body"
133-  local role_error_var="${label}_role_error"
134-
135-  printf '%-10s %s healthz=%s readyz=%s rolez=%s\n' \
136-    "${label}" \
137-    "${base_url}" \
138-    "$(format_probe_result "${!health_status_var:-n/a}" "${!health_body_var:-}" "${!health_error_var:-}")" \
139-    "$(format_probe_result "${!ready_status_var:-n/a}" "${!ready_body_var:-}" "${!ready_error_var:-}")" \
140-    "$(format_probe_result "${!role_status_var:-n/a}" "${!role_body_var:-}" "${!role_error_var:-}")"
141-}
142-
143-assert_text_response() {
144-  local label="$1"
145-  local expected_status="$2"
146-  local expected_body="$3"
147-  local actual_status="$4"
148-  local actual_body="$5"
149-  local actual_error="$6"
150-
151-  if [[ "$actual_status" == "curl_error" ]]; then
152-    record_failure "${label} request failed: ${actual_error}"
153-    return 0
154-  fi
155-
156-  if [[ "$actual_status" != "$expected_status" || "$actual_body" != "$expected_body" ]]; then
157-    record_failure "${label} expected ${expected_status}(${expected_body}), got ${actual_status}(${actual_body})"
158-  fi
159-}
160-
161-assert_surface() {
162-  local label="$1"
163-  local expected_role="$2"
164-  local health_status_var="${label}_health_status"
165-  local health_body_var="${label}_health_body"
166-  local health_error_var="${label}_health_error"
167-  local ready_status_var="${label}_ready_status"
168-  local ready_body_var="${label}_ready_body"
169-  local ready_error_var="${label}_ready_error"
170-  local role_status_var="${label}_role_status"
171-  local role_body_var="${label}_role_body"
172-  local role_error_var="${label}_role_error"
173-
174-  assert_text_response "${label} /healthz" "200" "ok" "${!health_status_var:-}" "${!health_body_var:-}" "${!health_error_var:-}"
175-  assert_text_response "${label} /readyz" "200" "ready" "${!ready_status_var:-}" "${!ready_body_var:-}" "${!ready_error_var:-}"
176-  assert_text_response "${label} /rolez" "200" "$expected_role" "${!role_status_var:-}" "${!role_body_var:-}" "${!role_error_var:-}"
177-}
178-
179-parse_system_state_json() {
180-  node -e 'const fs = require("fs");
181-const payload = JSON.parse(fs.readFileSync(0, "utf8"));
182-const pick = (...values) => values.find((value) => value !== undefined && value !== null);
183-const mode = pick(payload.data && payload.data.mode, payload.mode, payload.automation && payload.automation.mode, "");
184-const holder = pick(payload.data && payload.data.holder_id, payload.holder_id, payload.leader && payload.leader.controller_id, "");
185-const term = pick(payload.data && payload.data.term, payload.term, payload.leader && payload.leader.term, "");
186-const lease = pick(payload.data && payload.data.lease_expires_at, payload.lease_expires_at, payload.leader && payload.leader.lease_expires_at, "");
187-process.stdout.write([mode, holder, term, lease].map((value) => value == null ? "" : String(value)).join("\t"));'
188-}
189-
190-while [[ $# -gt 0 ]]; do
191-  case "$1" in
192-    --env)
193-      env_path="$2"
194-      shift 2
195-      ;;
196-    --basic-auth)
197-      basic_auth="$2"
198-      shift 2
199-      ;;
200-    --bearer-token)
201-      bearer_token="$2"
202-      shift 2
203-      ;;
204-    --bearer-token-file)
205-      bearer_token_file="$2"
206-      shift 2
207-      ;;
208-    --control-api-base)
209-      control_api_base="$2"
210-      shift 2
211-      ;;
212-    --expect-leader)
213-      validate_node "$2"
214-      expect_leader="$2"
215-      shift 2
216-      ;;
217-    --skip-node)
218-      validate_node "$2"
219-      if ! contains_value "$2" "${skip_nodes[@]-}"; then
220-        skip_nodes+=("$2")
221-      fi
222-      shift 2
223-      ;;
224-    --skip-public)
225-      skip_public="1"
226-      shift
227-      ;;
228-    --skip-control-api)
229-      skip_control_api="1"
230-      shift
231-      ;;
232-    --timeout)
233-      timeout_sec="$2"
234-      shift 2
235-      ;;
236-    --help)
237-      usage
238-      exit 0
239-      ;;
240-    *)
241-      die "Unknown option: $1"
242-      ;;
243-  esac
244-done
245-
246-load_inventory "$env_path"
247-
248-if [[ -n "$bearer_token_file" ]]; then
249-  if [[ ! -f "$bearer_token_file" ]]; then
250-    die "Bearer token file not found: ${bearer_token_file}"
251-  fi
252-  bearer_token="$(tr -d '\r\n' < "$bearer_token_file")"
253-fi
254-
255-if [[ -z "$control_api_base" ]]; then
256-  control_api_base="$FAILOVER_CONTROL_API_BASE"
257-fi
258-
259-if [[ -z "$basic_auth" ]]; then
260-  if ! contains_value mini "${skip_nodes[@]-}"; then
261-    skip_nodes+=("mini")
262-  fi
263-  if ! contains_value mac "${skip_nodes[@]-}"; then
264-    skip_nodes+=("mac")
265-  fi
266-  failover_warn "No direct-node Basic Auth configured; skipping mini/mac direct probes."
267-fi
268-
269-if [[ -n "$expect_leader" ]]; then
270-  if [[ -z "$bearer_token" ]] && contains_value mini "${skip_nodes[@]-}" && contains_value mac "${skip_nodes[@]-}" ; then
271-    die "Cannot verify --expect-leader without direct-node auth or a control API bearer token."
272-  fi
273-fi
274-
275-basic_auth_args=()
276-if [[ -n "$basic_auth" ]]; then
277-  basic_auth_args=(-u "$basic_auth")
278-fi
279-
280-printf 'Failover rehearsal snapshot\n'
281-printf 'inventory   %s\n' "$FAILOVER_ENV_PATH"
282-
283-if [[ "$skip_public" != "1" ]]; then
284-  public_base_url="https://${FAILOVER_CONDUCTOR_HOST}"
285-  probe_surface "public" "$public_base_url"
286-  print_surface_summary "public" "$public_base_url"
287-  assert_surface "public" "leader"
288-else
289-  printf '%-10s skipped\n' "public"
290-fi
291-
292-if ! contains_value mini "${skip_nodes[@]-}"; then
293-  mini_base_url="https://${FAILOVER_MINI_DIRECT_HOST}"
294-  probe_surface "mini" "$mini_base_url" "${basic_auth_args[@]}"
295-  print_surface_summary "mini" "$mini_base_url"
296-else
297-  printf '%-10s skipped\n' "mini"
298-fi
299-
300-if ! contains_value mac "${skip_nodes[@]-}"; then
301-  mac_base_url="https://${FAILOVER_MAC_DIRECT_HOST}"
302-  probe_surface "mac" "$mac_base_url" "${basic_auth_args[@]}"
303-  print_surface_summary "mac" "$mac_base_url"
304-else
305-  printf '%-10s skipped\n' "mac"
306-fi
307-
308-control_mode=""
309-control_holder=""
310-control_term=""
311-control_lease_expires_at=""
312-
313-if [[ "$skip_control_api" != "1" && -n "$bearer_token" ]]; then
314-  control_state_url="${control_api_base%/}/v1/system/state"
315-  probe_endpoint "control_status" "control_body" "control_error" "$control_state_url" \
316-    -H "Authorization: Bearer ${bearer_token}" \
317-    -H "Accept: application/json" || true
318-
319-  if [[ "${control_status:-}" == "curl_error" ]]; then
320-    printf '%-10s %s %s\n' "control" "$control_state_url" "$(format_probe_result "$control_status" "" "$control_error")"
321-    record_failure "control API /v1/system/state request failed: ${control_error}"
322-  elif [[ "${control_status:-}" != "200" ]]; then
323-    printf '%-10s %s %s\n' "control" "$control_state_url" "$(format_probe_result "$control_status" "$control_body" "$control_error")"
324-    record_failure "control API /v1/system/state expected 200, got ${control_status}(${control_body})"
325-  else
326-    parsed_control_state="$(printf '%s' "$control_body" | parse_system_state_json 2>/dev/null || true)"
327-    if [[ -z "$parsed_control_state" ]]; then
328-      printf '%-10s %s 200(raw=%s)\n' "control" "$control_state_url" "$control_body"
329-      record_failure "control API /v1/system/state returned JSON that could not be normalized"
330-    else
331-      IFS=$'\t' read -r control_mode control_holder control_term control_lease_expires_at <<<"$parsed_control_state"
332-      printf '%-10s %s mode=%s holder_id=%s term=%s lease_expires_at=%s\n' \
333-        "control" \
334-        "$control_state_url" \
335-        "${control_mode:-<empty>}" \
336-        "${control_holder:-<empty>}" \
337-        "${control_term:-<empty>}" \
338-        "${control_lease_expires_at:-<empty>}"
339-    fi
340-  fi
341-else
342-  printf '%-10s skipped\n' "control"
343-fi
344-
345-if [[ -z "$expect_leader" ]]; then
346-  if ! contains_value mini "${skip_nodes[@]-}" && ! contains_value mac "${skip_nodes[@]-}"; then
347-    mini_role="${mini_role_body:-}"
348-    mac_role="${mac_role_body:-}"
349-    case "${mini_role}:${mac_role}" in
350-      leader:standby | standby:leader) ;;
351-      *)
352-        record_failure "Expected exactly one direct node leader, got mini=${mini_role:-<empty>} mac=${mac_role:-<empty>}"
353-        ;;
354-    esac
355-  fi
356-else
357-  if ! contains_value "$expect_leader" "${skip_nodes[@]-}"; then
358-    assert_surface "$expect_leader" "leader"
359-  fi
360-
361-  other_node="mini"
362-  if [[ "$expect_leader" == "mini" ]]; then
363-    other_node="mac"
364-  fi
365-
366-  if ! contains_value "$other_node" "${skip_nodes[@]-}"; then
367-    assert_surface "$other_node" "standby"
368-  fi
369-
370-  if [[ -n "$control_holder" ]]; then
371-    case "$control_holder" in
372-      "${expect_leader}"-*) ;;
373-      *)
374-        record_failure "control API holder_id expected prefix ${expect_leader}-, got ${control_holder}"
375-        ;;
376-    esac
377-  fi
378-fi
379-
380-if [[ "$failures" -gt 0 ]]; then
381-  failover_error "rehearsal checks failed with ${failures} issue(s)"
382-  exit 1
383-fi
384-
385-failover_log "rehearsal checks passed"
M scripts/ops/baa-conductor.env.example
+1, -7
 1@@ -1,6 +1,5 @@
 2 # Copy this file to a private path outside the repo, then fill the real values.
 3-# The committed example keeps the current fourth-wave hostnames and Tailscale 100.x
 4-# upstreams, but uses placeholder public IP and Cloudflare metadata.
 5+# Current mainline keeps one public ingress and one mini node.
 6 
 7 BAA_APP_NAME=baa-conductor
 8 
 9@@ -9,18 +8,13 @@ BAA_CF_ZONE_ID=REPLACE_WITH_CLOUDFLARE_ZONE_ID
10 BAA_CF_API_TOKEN_ENV=CLOUDFLARE_API_TOKEN
11 BAA_CF_TTL=1
12 BAA_CF_PROXY_CONDUCTOR=true
13-BAA_CF_PROXY_MINI=true
14-BAA_CF_PROXY_MAC=true
15 
16 BAA_PUBLIC_IPV4=203.0.113.10
17 BAA_PUBLIC_IPV6=
18 
19 BAA_CONDUCTOR_HOST=conductor.makefile.so
20-BAA_MINI_DIRECT_HOST=mini-conductor.makefile.so
21-BAA_MAC_DIRECT_HOST=mac-conductor.makefile.so
22 
23 BAA_MINI_TAILSCALE_IP=100.71.210.78
24-BAA_MAC_TAILSCALE_IP=100.112.239.13
25 BAA_CONDUCTOR_PORT=4317
26 
27 BAA_NGINX_SITE_NAME=baa-conductor.conf
M scripts/ops/lib/ops-config.mjs
+0, -29
 1@@ -71,8 +71,6 @@ export function loadOpsConfig(inputPath) {
 2       ttl: parseNumber(env.BAA_CF_TTL ?? "1", "BAA_CF_TTL"),
 3       proxied: {
 4         conductor: parseBoolean(env.BAA_CF_PROXY_CONDUCTOR ?? "true", "BAA_CF_PROXY_CONDUCTOR"),
 5-        mini: parseBoolean(env.BAA_CF_PROXY_MINI ?? "true", "BAA_CF_PROXY_MINI"),
 6-        mac: parseBoolean(env.BAA_CF_PROXY_MAC ?? "true", "BAA_CF_PROXY_MAC"),
 7       },
 8     },
 9     vps: {
10@@ -81,12 +79,9 @@ export function loadOpsConfig(inputPath) {
11     },
12     hosts: {
13       conductor: requiredValue(env, "BAA_CONDUCTOR_HOST"),
14-      mini: requiredValue(env, "BAA_MINI_DIRECT_HOST"),
15-      mac: requiredValue(env, "BAA_MAC_DIRECT_HOST"),
16     },
17     tailscale: {
18       mini: requiredValue(env, "BAA_MINI_TAILSCALE_IP"),
19-      mac: requiredValue(env, "BAA_MAC_TAILSCALE_IP"),
20       port: parseNumber(env.BAA_CONDUCTOR_PORT ?? "4317", "BAA_CONDUCTOR_PORT"),
21     },
22     nginx: {
23@@ -110,20 +105,8 @@ export function buildHostInventory(config) {
24       key: "conductor",
25       hostname: config.hosts.conductor,
26       proxied: config.cloudflare.proxied.conductor,
27-      description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}, backup mac ${config.tailscale.mac}:${config.tailscale.port}`,
28-    },
29-    {
30-      key: "mini",
31-      hostname: config.hosts.mini,
32-      proxied: config.cloudflare.proxied.mini,
33       description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}`,
34     },
35-    {
36-      key: "mac",
37-      hostname: config.hosts.mac,
38-      proxied: config.cloudflare.proxied.mac,
39-      description: `public ingress via VPS -> mac ${config.tailscale.mac}:${config.tailscale.port}`,
40-    },
41   ];
42 }
43 
44@@ -168,19 +151,11 @@ export function getNginxTemplateTokens(config) {
45     "__NGINX_SITE_ENABLED_PATH__": `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`,
46     "__NGINX_INCLUDE_GLOB__": `${config.nginx.includeDir}/*.conf`,
47     "__CONDUCTOR_HOST__": config.hosts.conductor,
48-    "__MINI_DIRECT_HOST__": config.hosts.mini,
49-    "__MAC_DIRECT_HOST__": config.hosts.mac,
50     "__MINI_TAILSCALE_IP__": config.tailscale.mini,
51-    "__MAC_TAILSCALE_IP__": config.tailscale.mac,
52     "__CONDUCTOR_PORT__": String(config.tailscale.port),
53     "__NGINX_INCLUDE_DIR__": config.nginx.includeDir,
54-    "__NGINX_HTPASSWD_PATH__": config.nginx.htpasswdPath,
55     "__CONDUCTOR_CERT_FULLCHAIN__": certificatePath(config, config.hosts.conductor, "fullchain.pem"),
56     "__CONDUCTOR_CERT_KEY__": certificatePath(config, config.hosts.conductor, "privkey.pem"),
57-    "__MINI_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mini, "fullchain.pem"),
58-    "__MINI_CERT_KEY__": certificatePath(config, config.hosts.mini, "privkey.pem"),
59-    "__MAC_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mac, "fullchain.pem"),
60-    "__MAC_CERT_KEY__": certificatePath(config, config.hosts.mac, "privkey.pem"),
61   };
62 }
63 
64@@ -205,7 +180,6 @@ export function buildRenderedNginxArtifacts(config, templates) {
65 
66   return {
67     siteConf: renderTemplate(templates.siteConf, tokens),
68-    directNodeAuth: renderTemplate(templates.directNodeAuth, tokens),
69     commonProxy: templates.commonProxy,
70   };
71 }
72@@ -266,10 +240,7 @@ function requiredValue(env, key) {
73 
74 function validateConfig(config) {
75   validatePublicHostname(config.hosts.conductor, "BAA_CONDUCTOR_HOST");
76-  validatePublicHostname(config.hosts.mini, "BAA_MINI_DIRECT_HOST");
77-  validatePublicHostname(config.hosts.mac, "BAA_MAC_DIRECT_HOST");
78   validateTailscaleIpv4(config.tailscale.mini, "BAA_MINI_TAILSCALE_IP");
79-  validateTailscaleIpv4(config.tailscale.mac, "BAA_MAC_TAILSCALE_IP");
80 }
81 
82 function parseBoolean(value, key) {
M scripts/ops/nginx-sync-plan.mjs
+2, -14
 1@@ -21,9 +21,9 @@ const usage = `Usage:
 2   node scripts/ops/nginx-sync-plan.mjs [--env PATH] [--bundle-dir PATH] [--check-repo]
 3 
 4 Behavior:
 5-  - Render the committed Nginx templates from the inventory file.
 6+  - Render the committed Nginx template from the inventory file.
 7   - Stage a deploy bundle with deploy-on-vps.sh.
 8-  - --check-repo compares the rendered output against ops/nginx/*.conf in the repo.`;
 9+  - --check-repo compares the rendered output against ops/nginx/baa-conductor.conf in the repo.`;
10 
11 function main() {
12   const args = parseCliArgs(process.argv.slice(2));
13@@ -55,7 +55,6 @@ function main() {
14 function loadTemplates() {
15   return {
16     siteConf: readFileSync(resolve(repoRoot, "ops/nginx/templates/baa-conductor.conf.template"), "utf8"),
17-    directNodeAuth: readFileSync(resolve(repoRoot, "ops/nginx/templates/includes/direct-node-auth.conf.template"), "utf8"),
18     commonProxy: readFileSync(resolve(repoRoot, "ops/nginx/includes/common-proxy.conf"), "utf8"),
19   };
20 }
21@@ -63,16 +62,11 @@ function loadTemplates() {
22 function compareWithRepo(rendered) {
23   const mismatches = [];
24   const repoSiteConf = readFileSync(resolve(repoRoot, "ops/nginx/baa-conductor.conf"), "utf8");
25-  const repoDirectNodeAuth = readFileSync(resolve(repoRoot, "ops/nginx/includes/direct-node-auth.conf"), "utf8");
26 
27   if (repoSiteConf !== rendered.siteConf) {
28     mismatches.push("ops/nginx/baa-conductor.conf");
29   }
30 
31-  if (repoDirectNodeAuth !== rendered.directNodeAuth) {
32-    mismatches.push("ops/nginx/includes/direct-node-auth.conf");
33-  }
34-
35   return {
36     clean: mismatches.length === 0,
37     mismatches,
38@@ -83,7 +77,6 @@ function writeBundle(bundleDir, config, rendered) {
39   const siteTargetPath = resolve(bundleDir, stripLeadingSlash(config.nginx.siteInstallDir), config.nginx.siteName);
40   const includeRoot = resolve(bundleDir, stripLeadingSlash(config.nginx.includeDir));
41   const commonProxyPath = resolve(includeRoot, "common-proxy.conf");
42-  const directNodeAuthPath = resolve(includeRoot, "direct-node-auth.conf");
43   const deployScriptPath = resolve(bundleDir, "deploy-on-vps.sh");
44   const summaryPath = resolve(bundleDir, "inventory-summary.json");
45   const deployCommandsPath = resolve(bundleDir, "DEPLOY_COMMANDS.txt");
46@@ -93,7 +86,6 @@ function writeBundle(bundleDir, config, rendered) {
47 
48   writeFileSync(siteTargetPath, rendered.siteConf, "utf8");
49   writeFileSync(commonProxyPath, rendered.commonProxy, "utf8");
50-  writeFileSync(directNodeAuthPath, rendered.directNodeAuth, "utf8");
51   writeFileSync(summaryPath, `${JSON.stringify(buildSummary(config), null, 2)}\n`, "utf8");
52   writeFileSync(deployCommandsPath, `${buildDeployCommands(bundleDir)}\n`, "utf8");
53   writeFileSync(deployScriptPath, buildDeployScript(config), "utf8");
54@@ -115,7 +107,6 @@ function buildSummary(config) {
55     desired_dns_records: desiredDnsRecords,
56     tailscale: {
57       mini: `${config.tailscale.mini}:${config.tailscale.port}`,
58-      mac: `${config.tailscale.mac}:${config.tailscale.port}`,
59     },
60     nginx: config.nginx,
61   };
62@@ -137,11 +128,9 @@ function buildDeployCommands(bundleDir) {
63 function buildDeployScript(config) {
64   const siteSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.siteInstallDir)}/${config.nginx.siteName}"`;
65   const commonProxySource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/common-proxy.conf"`;
66-  const directAuthSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/direct-node-auth.conf"`;
67   const siteTarget = `${config.nginx.siteInstallDir}/${config.nginx.siteName}`;
68   const siteEnabledTarget = `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`;
69   const commonProxyTarget = `${config.nginx.includeDir}/common-proxy.conf`;
70-  const directAuthTarget = `${config.nginx.includeDir}/direct-node-auth.conf`;
71 
72   return `#!/usr/bin/env bash
73 set -euo pipefail
74@@ -159,7 +148,6 @@ install -d -m 0755 ${config.nginx.includeDir}
75 
76 install -m 0644 ${siteSource} "${siteTarget}"
77 install -m 0644 ${commonProxySource} "${commonProxyTarget}"
78-install -m 0644 ${directAuthSource} "${directAuthTarget}"
79 ln -sfn "${siteTarget}" "${siteEnabledTarget}"
80 
81 nginx -t
M scripts/runtime/check-launchd.sh
+1, -1
1@@ -11,7 +11,7 @@ Usage:
2   scripts/runtime/check-launchd.sh [options]
3 
4 Options:
5-  --node mini|mac           Select node defaults. Defaults to mini.
6+  --node mini               Select node defaults. Defaults to mini.
7   --scope agent|daemon      Expected launchd scope for install copies. Defaults to agent.
8   --service NAME            Add one service to the check set. Repeatable.
9   --all-services            Check conductor, worker-runner, and status-api.
M scripts/runtime/check-node.sh
+6, -9
 1@@ -11,7 +11,7 @@ Usage:
 2   scripts/runtime/check-node.sh [options]
 3 
 4 Options:
 5-  --node mini|mac              Select node defaults. Defaults to mini.
 6+  --node mini                  Select node defaults. Defaults to mini.
 7   --scope agent|daemon         Expected launchd scope. Defaults to agent.
 8   --service NAME               Add one service to the runtime check set. Repeatable.
 9   --all-services               Check conductor, worker-runner, and status-api.
10@@ -29,7 +29,7 @@ Options:
11   --username NAME              Expected UserName for LaunchDaemons.
12   --domain TARGET              launchctl domain target for --check-loaded.
13   --check-loaded               Also require launchctl print to succeed for each service.
14-  --expected-rolez VALUE       Expected conductor /rolez body: leader, standby, or any.
15+  --expected-rolez VALUE       Expected conductor /rolez body: leader or any.
16   --skip-static-check          Skip the underlying check-launchd.sh pass.
17   --skip-port-check            Skip local TCP LISTEN checks.
18   --skip-process-check         Skip host process command-line checks.
19@@ -187,7 +187,7 @@ validate_node "$node"
20 validate_scope "$scope"
21 
22 case "$expected_rolez" in
23-  any | leader | standby) ;;
24+  any | leader) ;;
25   *)
26     die "Unsupported --expected-rolez value: ${expected_rolez}"
27     ;;
28@@ -423,12 +423,9 @@ check_conductor_runtime() {
29 
30     case "$expected_rolez" in
31       any)
32-        case "$HTTP_BODY" in
33-          leader | standby) ;;
34-          *)
35-            die "conductor /rolez must be leader or standby, got '${HTTP_BODY}'"
36-            ;;
37-        esac
38+        if [[ -z "$HTTP_BODY" ]]; then
39+          die "conductor /rolez returned an empty body"
40+        fi
41         ;;
42       *)
43         if [[ "$HTTP_BODY" != "$expected_rolez" ]]; then
M scripts/runtime/common.sh
+1, -4
 1@@ -65,7 +65,7 @@ validate_scope() {
 2 
 3 validate_node() {
 4   case "$1" in
 5-    mini | mac) ;;
 6+    mini) ;;
 7     *)
 8       die "Unsupported node: $1"
 9       ;;
10@@ -178,9 +178,6 @@ resolve_node_defaults() {
11     mini)
12       printf '%s %s %s\n' "mini" "primary" "mini-main"
13       ;;
14-    mac)
15-      printf '%s %s %s\n' "mac" "standby" "mac-standby"
16-      ;;
17   esac
18 }
19 
M scripts/runtime/install-launchd.sh
+1, -1
1@@ -11,7 +11,7 @@ Usage:
2   scripts/runtime/install-launchd.sh [options]
3 
4 Options:
5-  --node mini|mac           Select node defaults. Defaults to mini.
6+  --node mini               Select node defaults. Defaults to mini.
7   --scope agent|daemon      Install under LaunchAgents or LaunchDaemons. Defaults to agent.
8   --service NAME            Add one service to the install set. Repeatable.
9   --all-services            Install conductor, worker-runner, and status-api templates.
D scripts/smoke/README.md
+0, -47
 1@@ -1,47 +0,0 @@
 2-# Smoke Harness
 3-
 4-最小本地 smoke 流程:
 5-
 6-```bash
 7-bash scripts/smoke/run-e2e.sh --json
 8-```
 9-
10-如果要分步执行:
11-
12-```bash
13-bash scripts/smoke/start-stack.sh --json
14-bash scripts/smoke/check-stack.sh --state-dir <tmp/smoke-...> --expected-leader smoke-mini --json
15-bash scripts/smoke/stop-stack.sh --state-dir <tmp/smoke-...> --json
16-```
17-
18-`run-e2e.sh` 会完成这些动作:
19-
20-- 构建 `control-api-worker` 和 `status-api` 的本地运行产物
21-- 启动本地 `control-api`、主 conductor、备 conductor、`status-api`
22-- 验证 `healthz` / `readyz` / `rolez` / `/v1/system/state` / `/v1/status`
23-- 创建一个 queued task,确认 `status-api` 的 `queueDepth` 能读到
24-- 执行一次最小主备切换,确认 lease 从 `smoke-mini` 漂移到 `smoke-mac`
25-
26-所有临时状态、数据库和日志都会落到 `tmp/smoke-*` 目录。
27-
28-## Live 环境快照
29-
30-真实环境回归可以直接跑:
31-
32-```bash
33-node scripts/smoke/live-regression.mjs \
34-  --env /Users/george/.config/baa-conductor/ops.env \
35-  --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env \
36-  --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env \
37-  --expect-leader mini
38-```
39-
40-它会一次性采样这些面:
41-
42-- `GET https://control-api.makefile.so/v1/system/state`
43-- `conductor.makefile.so` 的 `/healthz` `/readyz` `/rolez`
44-- `mini-conductor.makefile.so` / `mac-conductor.makefile.so` 的 Basic Auth 与 `/healthz` `/readyz` `/rolez`
45-- `http://100.71.210.78:4318/v1/status`
46-- `http://100.112.239.13:4318/v1/status`
47-
48-默认输出 JSON 快照,并把 status-api 视图是否和 control-api 当前 leader / mode 对齐也一起标出来。
D scripts/smoke/check-stack.sh
+0, -6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" check "$@"
D scripts/smoke/control-api-local.mjs
+0, -164
  1@@ -1,164 +0,0 @@
  2-import { createServer } from "node:http";
  3-import controlApiWorker from "../../apps/control-api-worker/dist/index.js";
  4-import { initializeSqliteD1Database } from "./d1-sqlite.mjs";
  5-
  6-function parseArgs(argv) {
  7-  const options = {
  8-    authRequired: false,
  9-    databasePath: null,
 10-    host: "127.0.0.1",
 11-    port: null
 12-  };
 13-
 14-  for (let index = 0; index < argv.length; index += 1) {
 15-    const token = argv[index];
 16-
 17-    switch (token) {
 18-      case "--auth-required":
 19-        options.authRequired = true;
 20-        break;
 21-      case "--db":
 22-        options.databasePath = readValue(argv, token, index);
 23-        index += 1;
 24-        break;
 25-      case "--host":
 26-        options.host = readValue(argv, token, index);
 27-        index += 1;
 28-        break;
 29-      case "--port":
 30-        options.port = parsePort(readValue(argv, token, index), token);
 31-        index += 1;
 32-        break;
 33-      default:
 34-        throw new Error(`Unknown control-api-local option "${token}".`);
 35-    }
 36-  }
 37-
 38-  if (!options.databasePath) {
 39-    throw new Error("--db is required.");
 40-  }
 41-
 42-  if (options.port == null) {
 43-    throw new Error("--port is required.");
 44-  }
 45-
 46-  return options;
 47-}
 48-
 49-function readValue(tokens, flag, index) {
 50-  const value = tokens[index + 1];
 51-
 52-  if (!value || value.startsWith("--")) {
 53-    throw new Error(`Missing value for ${flag}.`);
 54-  }
 55-
 56-  return value;
 57-}
 58-
 59-function parsePort(value, flag) {
 60-  const port = Number(value);
 61-
 62-  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
 63-    throw new Error(`Invalid value for ${flag}: "${value}".`);
 64-  }
 65-
 66-  return port;
 67-}
 68-
 69-async function readRequestBody(request) {
 70-  const chunks = [];
 71-
 72-  for await (const chunk of request) {
 73-    chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
 74-  }
 75-
 76-  return Buffer.concat(chunks);
 77-}
 78-
 79-async function main() {
 80-  const options = parseArgs(process.argv.slice(2));
 81-  const db = initializeSqliteD1Database({
 82-    databasePath: options.databasePath
 83-  });
 84-  const env = {
 85-    CONTROL_API_AUTH_REQUIRED: options.authRequired ? "true" : "false",
 86-    CONTROL_API_VERSION: "smoke-local",
 87-    CONTROL_DB: db
 88-  };
 89-  const baseUrl = `http://${options.host}:${options.port}`;
 90-  const server = createServer((request, response) => {
 91-    void handleRequest(request, response, baseUrl, env);
 92-  });
 93-
 94-  const close = async () => {
 95-    await new Promise((resolve, reject) => {
 96-      server.close((error) => {
 97-        if (error) {
 98-          reject(error);
 99-          return;
100-        }
101-
102-        resolve();
103-      });
104-    });
105-    db.close();
106-  };
107-
108-  const shutdown = (signal) => {
109-    void close().finally(() => {
110-      process.exitCode = 0;
111-      if (signal) {
112-        process.stderr.write(`control-api-local stopped after ${signal}\n`);
113-      }
114-    });
115-  };
116-
117-  process.once("SIGINT", () => shutdown("SIGINT"));
118-  process.once("SIGTERM", () => shutdown("SIGTERM"));
119-
120-  await new Promise((resolve) => {
121-    server.listen(options.port, options.host, resolve);
122-  });
123-
124-  process.stdout.write(`control-api-local listening on ${baseUrl}\n`);
125-}
126-
127-async function handleRequest(request, response, baseUrl, env) {
128-  try {
129-    const body = await readRequestBody(request);
130-    const url = new URL(request.url ?? "/", baseUrl);
131-    const workerRequest = new Request(url, {
132-      body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
133-      headers: new Headers(request.headers),
134-      method: request.method ?? "GET"
135-    });
136-    const workerResponse = await controlApiWorker.fetch(workerRequest, env, {
137-      passThroughOnException() {},
138-      waitUntil() {}
139-    });
140-
141-    response.statusCode = workerResponse.status;
142-
143-    for (const [name, value] of workerResponse.headers.entries()) {
144-      response.setHeader(name, value);
145-    }
146-
147-    response.end(Buffer.from(await workerResponse.arrayBuffer()));
148-  } catch (error) {
149-    response.statusCode = 500;
150-    response.setHeader("content-type", "application/json; charset=utf-8");
151-    response.end(
152-      `${JSON.stringify(
153-        {
154-          ok: false,
155-          error: "control_api_local_failure",
156-          message: error instanceof Error ? error.message : String(error)
157-        },
158-        null,
159-        2
160-      )}\n`
161-    );
162-  }
163-}
164-
165-await main();
D scripts/smoke/d1-sqlite.mjs
+0, -165
  1@@ -1,165 +0,0 @@
  2-import { mkdirSync, readFileSync } from "node:fs";
  3-import { dirname, resolve } from "node:path";
  4-import { DatabaseSync } from "node:sqlite";
  5-import { fileURLToPath } from "node:url";
  6-
  7-const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
  8-const DEFAULT_AUTOMATION_MODE = "running";
  9-
 10-function normalizeRow(row) {
 11-  if (row == null || typeof row !== "object" || Array.isArray(row)) {
 12-    return row ?? null;
 13-  }
 14-
 15-  return { ...row };
 16-}
 17-
 18-function toLastRowId(value) {
 19-  if (typeof value === "bigint") {
 20-    const normalized = Number(value);
 21-    return Number.isSafeInteger(normalized) ? normalized : undefined;
 22-  }
 23-
 24-  return typeof value === "number" ? value : undefined;
 25-}
 26-
 27-class SqliteD1PreparedStatement {
 28-  constructor(database, query, values = []) {
 29-    this.database = database;
 30-    this.query = query;
 31-    this.values = [...values];
 32-  }
 33-
 34-  bind(...values) {
 35-    return new SqliteD1PreparedStatement(this.database, this.query, values);
 36-  }
 37-
 38-  async all() {
 39-    const rows = this.#statement().all(...this.values).map(normalizeRow);
 40-
 41-    return {
 42-      meta: {
 43-        rows_read: rows.length
 44-      },
 45-      results: rows,
 46-      success: true
 47-    };
 48-  }
 49-
 50-  async first(columnName) {
 51-    const row = normalizeRow(this.#statement().get(...this.values));
 52-
 53-    if (row == null) {
 54-      return null;
 55-    }
 56-
 57-    if (columnName == null) {
 58-      return row;
 59-    }
 60-
 61-    return Object.prototype.hasOwnProperty.call(row, columnName) ? row[columnName] ?? null : null;
 62-  }
 63-
 64-  async raw(options = {}) {
 65-    const statement = this.#statement();
 66-    const columns = statement.columns().map((column) => column.name);
 67-    const rows = statement.all(...this.values).map((row) => columns.map((columnName) => row[columnName] ?? null));
 68-
 69-    return options.columnNames ? [columns, ...rows] : rows;
 70-  }
 71-
 72-  async run() {
 73-    const result = this.#statement().run(...this.values);
 74-
 75-    return {
 76-      meta: {
 77-        changes: result.changes,
 78-        last_row_id: toLastRowId(result.lastInsertRowid)
 79-      },
 80-      success: true
 81-    };
 82-  }
 83-
 84-  #statement() {
 85-    return this.database.prepare(this.query);
 86-  }
 87-}
 88-
 89-export class SqliteD1Database {
 90-  constructor(databasePath) {
 91-    const resolvedPath = resolve(databasePath);
 92-    mkdirSync(dirname(resolvedPath), { recursive: true });
 93-    this.path = resolvedPath;
 94-    this.database = new DatabaseSync(this.path);
 95-    this.database.exec("PRAGMA journal_mode = WAL;");
 96-    this.database.exec("PRAGMA synchronous = NORMAL;");
 97-    this.database.exec("PRAGMA foreign_keys = ON;");
 98-    this.database.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
 99-  }
100-
101-  async batch(statements) {
102-    const results = [];
103-
104-    this.database.exec("BEGIN IMMEDIATE;");
105-
106-    try {
107-      for (const statement of statements) {
108-        if (!(statement instanceof SqliteD1PreparedStatement)) {
109-          throw new TypeError("SqliteD1Database.batch only accepts statements created by this adapter.");
110-        }
111-
112-        results.push(await statement.run());
113-      }
114-
115-      this.database.exec("COMMIT;");
116-      return results;
117-    } catch (error) {
118-      this.database.exec("ROLLBACK;");
119-      throw error;
120-    }
121-  }
122-
123-  close() {
124-    this.database.close();
125-  }
126-
127-  async exec(query) {
128-    this.database.exec(query);
129-    return {};
130-  }
131-
132-  prepare(query) {
133-    return new SqliteD1PreparedStatement(this.database, query);
134-  }
135-}
136-
137-export function createSqliteD1Database(databasePath) {
138-  return new SqliteD1Database(databasePath);
139-}
140-
141-export function getDefaultSmokeSchemaPath() {
142-  return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "ops", "sql", "schema.sql");
143-}
144-
145-export function initializeSqliteD1Database({
146-  databasePath,
147-  schemaPath = getDefaultSmokeSchemaPath(),
148-  automationMode = DEFAULT_AUTOMATION_MODE
149-}) {
150-  const db = createSqliteD1Database(databasePath);
151-  const schema = readFileSync(schemaPath, "utf8");
152-  const nowUnixSeconds = Math.floor(Date.now() / 1_000);
153-
154-  db.database.exec(schema);
155-  db.database
156-    .prepare(
157-      `
158-        INSERT INTO system_state (state_key, value_json, updated_at)
159-        VALUES (?, ?, ?)
160-        ON CONFLICT(state_key) DO NOTHING
161-      `
162-    )
163-    .run("automation", JSON.stringify({ mode: automationMode }), nowUnixSeconds);
164-
165-  return db;
166-}
D scripts/smoke/live-regression.mjs
+0, -473
  1@@ -1,473 +0,0 @@
  2-#!/usr/bin/env node
  3-
  4-import { execFile } from "node:child_process";
  5-import { readFileSync } from "node:fs";
  6-import { basename } from "node:path";
  7-import { promisify } from "node:util";
  8-
  9-const DEFAULT_CONTROL_API_BASE = "https://control-api.makefile.so";
 10-const DEFAULT_CONDUCTOR_PORT = 4317;
 11-const DEFAULT_STATUS_API_PORT = 4318;
 12-const DEFAULT_TIMEOUT_MS = 5000;
 13-const execFileAsync = promisify(execFile);
 14-
 15-function usage() {
 16-  process.stdout.write(`Usage:
 17-  node scripts/smoke/live-regression.mjs [options]
 18-
 19-Options:
 20-  --env PATH                 Inventory env file.
 21-  --control-secrets PATH     Secrets env file with control-api tokens.
 22-  --basic-auth-file PATH     Env file with direct-node Basic Auth credentials.
 23-  --bearer-token TOKEN       Override the bearer token used for GET /v1/system/state.
 24-  --basic-auth USER:PASS     Override the direct-node Basic Auth value.
 25-  --expect-leader VALUE      mini, mac, or any. Defaults to mini.
 26-  --timeout-ms N             Per-request timeout in milliseconds. Defaults to 5000.
 27-  --compact                  Emit compact JSON instead of pretty-printed JSON.
 28-  --strict                   Exit non-zero when assertions fail.
 29-  --help                     Show this help text.
 30-`);
 31-}
 32-
 33-function parseArgs(argv) {
 34-  const options = {
 35-    compact: false,
 36-    expectLeader: "mini",
 37-    strict: false,
 38-    timeoutMs: DEFAULT_TIMEOUT_MS
 39-  };
 40-
 41-  for (let index = 0; index < argv.length; index += 1) {
 42-    const token = argv[index];
 43-    switch (token) {
 44-      case "--env":
 45-        options.envPath = requireValue(argv, ++index, token);
 46-        break;
 47-      case "--control-secrets":
 48-        options.controlSecretsPath = requireValue(argv, ++index, token);
 49-        break;
 50-      case "--basic-auth-file":
 51-        options.basicAuthFile = requireValue(argv, ++index, token);
 52-        break;
 53-      case "--bearer-token":
 54-        options.bearerToken = requireValue(argv, ++index, token);
 55-        break;
 56-      case "--basic-auth":
 57-        options.basicAuth = requireValue(argv, ++index, token);
 58-        break;
 59-      case "--expect-leader":
 60-        options.expectLeader = requireValue(argv, ++index, token);
 61-        break;
 62-      case "--timeout-ms":
 63-        options.timeoutMs = Number.parseInt(requireValue(argv, ++index, token), 10);
 64-        break;
 65-      case "--compact":
 66-        options.compact = true;
 67-        break;
 68-      case "--strict":
 69-        options.strict = true;
 70-        break;
 71-      case "--help":
 72-        usage();
 73-        process.exit(0);
 74-      default:
 75-        throw new Error(`Unknown option "${token}".`);
 76-    }
 77-  }
 78-
 79-  if (!options.envPath) {
 80-    throw new Error("--env is required.");
 81-  }
 82-
 83-  if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
 84-    throw new Error("--timeout-ms must be a positive integer.");
 85-  }
 86-
 87-  if (!["mini", "mac", "any"].includes(options.expectLeader)) {
 88-    throw new Error(`Unsupported --expect-leader value "${options.expectLeader}".`);
 89-  }
 90-
 91-  return options;
 92-}
 93-
 94-function requireValue(argv, index, optionName) {
 95-  const value = argv[index];
 96-  if (!value) {
 97-    throw new Error(`${optionName} requires a value.`);
 98-  }
 99-  return value;
100-}
101-
102-function loadEnvFile(path) {
103-  const text = readFileSync(path, "utf8");
104-  const entries = {};
105-
106-  for (const rawLine of text.split(/\r?\n/u)) {
107-    const line = rawLine.trim();
108-    if (!line || line.startsWith("#")) {
109-      continue;
110-    }
111-
112-    const separatorIndex = line.indexOf("=");
113-    if (separatorIndex <= 0) {
114-      continue;
115-    }
116-
117-    const key = line.slice(0, separatorIndex).trim();
118-    let value = line.slice(separatorIndex + 1).trim();
119-
120-    if (
121-      (value.startsWith('"') && value.endsWith('"')) ||
122-      (value.startsWith("'") && value.endsWith("'"))
123-    ) {
124-      value = value.slice(1, -1);
125-    }
126-
127-    entries[key] = value;
128-  }
129-
130-  return entries;
131-}
132-
133-function pickBearerToken(options, secrets) {
134-  return (
135-    options.bearerToken ||
136-    secrets.CONTROL_API_OPS_ADMIN_TOKEN ||
137-    secrets.CONTROL_API_READONLY_TOKEN ||
138-    secrets.CONTROL_API_BROWSER_ADMIN_TOKEN ||
139-    ""
140-  );
141-}
142-
143-function pickBasicAuth(options, authEnv) {
144-  if (options.basicAuth) {
145-    return options.basicAuth;
146-  }
147-
148-  const user = authEnv.BAA_DIRECT_NODE_BASIC_AUTH_USER || "";
149-  const password = authEnv.BAA_DIRECT_NODE_BASIC_AUTH_PASSWORD || "";
150-  if (!user || !password) {
151-    return "";
152-  }
153-
154-  return `${user}:${password}`;
155-}
156-
157-function toEpochIso(value) {
158-  if (value === null || value === undefined || value === "") {
159-    return null;
160-  }
161-
162-  const numericValue = Number(value);
163-  if (!Number.isFinite(numericValue)) {
164-    return null;
165-  }
166-
167-  const millis = numericValue > 1_000_000_000_000 ? numericValue : numericValue * 1000;
168-  return new Date(millis).toISOString();
169-}
170-
171-function normalizeControlState(payload) {
172-  const data = payload?.data ?? payload ?? {};
173-  return {
174-    holderId: data.holder_id ?? payload?.holder_id ?? null,
175-    leaseExpiresAt: data.lease_expires_at ?? payload?.lease_expires_at ?? null,
176-    leaseExpiresAtIso: toEpochIso(data.lease_expires_at ?? payload?.lease_expires_at ?? null),
177-    mode: data.mode ?? payload?.mode ?? null,
178-    ok: payload?.ok === true,
179-    requestId: payload?.request_id ?? null,
180-    term: data.term ?? payload?.term ?? null
181-  };
182-}
183-
184-function normalizeStatusSnapshot(payload) {
185-  const data = payload?.data ?? payload ?? {};
186-  return {
187-    activeRuns: data.activeRuns ?? null,
188-    leaderHost: data.leaderHost ?? null,
189-    leaderId: data.leaderId ?? null,
190-    leaseActive: data.leaseActive ?? null,
191-    leaseExpiresAt: data.leaseExpiresAt ?? null,
192-    leaseTerm: data.leaseTerm ?? null,
193-    mode: data.mode ?? null,
194-    observedAt: data.observedAt ?? null,
195-    ok: payload?.ok === true,
196-    queueDepth: data.queueDepth ?? null,
197-    source: data.source ?? null,
198-    updatedAt: data.updatedAt ?? null
199-  };
200-}
201-
202-function expectedLeaderFromControl(controlState) {
203-  const holderId = controlState?.holderId ?? "";
204-  if (holderId.startsWith("mini-")) {
205-    return "mini";
206-  }
207-  if (holderId.startsWith("mac-")) {
208-    return "mac";
209-  }
210-  return null;
211-}
212-
213-async function fetchProbe(url, { headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
214-  const args = [
215-    "-sS",
216-    "-L",
217-    "--max-time",
218-    String(Math.max(1, Math.ceil(timeoutMs / 1000)))
219-  ];
220-
221-  for (const [key, value] of Object.entries(headers)) {
222-    args.push("-H", `${key}: ${value}`);
223-  }
224-
225-  args.push("-w", "\n__HTTP_STATUS__:%{http_code}", url);
226-
227-  try {
228-    const { stdout } = await execFileAsync("curl", args, {
229-      maxBuffer: 10 * 1024 * 1024,
230-      timeout: timeoutMs + 1000
231-    });
232-    const marker = "\n__HTTP_STATUS__:";
233-    const markerIndex = stdout.lastIndexOf(marker);
234-    const body = markerIndex >= 0 ? stdout.slice(0, markerIndex) : stdout;
235-    const statusText = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length).trim() : "";
236-    const httpStatus = Number.parseInt(statusText, 10);
237-
238-    return {
239-      body: body.replace(/\r/g, "").replace(/\n+$/u, ""),
240-      httpStatus: Number.isFinite(httpStatus) ? httpStatus : null,
241-      ok: httpStatus >= 200 && httpStatus < 300,
242-      url
243-    };
244-  } catch (error) {
245-    return {
246-      body: "",
247-      error:
248-        error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
249-          ? error.stderr.trim() || (error instanceof Error ? error.message : String(error))
250-          : error instanceof Error
251-            ? error.message
252-            : String(error),
253-      httpStatus: null,
254-      ok: false,
255-      url
256-    };
257-  }
258-}
259-
260-async function probeConductor(baseUrl, headers, timeoutMs) {
261-  const [healthz, readyz, rolez] = await Promise.all([
262-    fetchProbe(`${baseUrl}/healthz`, { headers, timeoutMs }),
263-    fetchProbe(`${baseUrl}/readyz`, { headers, timeoutMs }),
264-    fetchProbe(`${baseUrl}/rolez`, { headers, timeoutMs })
265-  ]);
266-
267-  return { baseUrl, healthz, readyz, rolez };
268-}
269-
270-async function probeStatusApi(baseUrl, timeoutMs) {
271-  const [healthz, status] = await Promise.all([
272-    fetchProbe(`${baseUrl}/healthz`, { timeoutMs }),
273-    fetchProbe(`${baseUrl}/v1/status`, { timeoutMs })
274-  ]);
275-
276-  let parsedStatus = null;
277-  if (!status.error && status.body) {
278-    try {
279-      parsedStatus = JSON.parse(status.body);
280-    } catch (error) {
281-      status.parseError = error instanceof Error ? error.message : String(error);
282-    }
283-  }
284-
285-  return {
286-    baseUrl,
287-    healthz,
288-    status,
289-    summary: parsedStatus ? normalizeStatusSnapshot(parsedStatus) : null
290-  };
291-}
292-
293-function addIssue(issues, condition, message) {
294-  if (!condition) {
295-    issues.push(message);
296-  }
297-}
298-
299-function responseMatches(probe, expectedStatus, expectedBody) {
300-  return probe?.httpStatus === expectedStatus && probe?.body === expectedBody;
301-}
302-
303-function statusMatchesControl(controlState, statusSummary) {
304-  if (!controlState?.holderId || !controlState?.mode || !statusSummary) {
305-    return false;
306-  }
307-
308-  return (
309-    statusSummary.ok === true &&
310-    statusSummary.mode === controlState.mode &&
311-    statusSummary.leaderId === controlState.holderId &&
312-    statusSummary.source !== "empty"
313-  );
314-}
315-
316-async function main() {
317-  const options = parseArgs(process.argv.slice(2));
318-  const inventory = loadEnvFile(options.envPath);
319-  const controlSecrets = options.controlSecretsPath ? loadEnvFile(options.controlSecretsPath) : {};
320-  const basicAuthEnv = options.basicAuthFile ? loadEnvFile(options.basicAuthFile) : {};
321-
322-  const bearerToken = pickBearerToken(options, controlSecrets);
323-  const basicAuth = pickBasicAuth(options, basicAuthEnv);
324-
325-  if (!bearerToken) {
326-    throw new Error("No bearer token available. Pass --bearer-token or --control-secrets.");
327-  }
328-
329-  if (!basicAuth) {
330-    throw new Error("No Basic Auth available. Pass --basic-auth or --basic-auth-file.");
331-  }
332-
333-  const controlApiBase = inventory.BAA_CONTROL_API_BASE || DEFAULT_CONTROL_API_BASE;
334-  const conductorPort = Number.parseInt(inventory.BAA_CONDUCTOR_PORT || "", 10) || DEFAULT_CONDUCTOR_PORT;
335-  const statusApiPort = DEFAULT_STATUS_API_PORT;
336-  const publicBaseUrl = `https://${inventory.BAA_CONDUCTOR_HOST}`;
337-  const miniDirectBaseUrl = `https://${inventory.BAA_MINI_DIRECT_HOST}`;
338-  const macDirectBaseUrl = `https://${inventory.BAA_MAC_DIRECT_HOST}`;
339-  const miniStatusBaseUrl = `http://${inventory.BAA_MINI_TAILSCALE_IP}:${statusApiPort}`;
340-  const macStatusBaseUrl = `http://${inventory.BAA_MAC_TAILSCALE_IP}:${statusApiPort}`;
341-
342-  const authHeaders = {
343-    Authorization: `Basic ${Buffer.from(basicAuth).toString("base64")}`
344-  };
345-
346-  const [controlStateProbe, publicConductor, miniNoAuth, miniDirect, macNoAuth, macDirect, miniStatus, macStatus] =
347-    await Promise.all([
348-      fetchProbe(`${controlApiBase.replace(/\/+$/u, "")}/v1/system/state`, {
349-        headers: {
350-          Accept: "application/json",
351-          Authorization: `Bearer ${bearerToken}`
352-        },
353-        timeoutMs: options.timeoutMs
354-      }),
355-      probeConductor(publicBaseUrl, {}, options.timeoutMs),
356-      fetchProbe(`${miniDirectBaseUrl}/healthz`, { timeoutMs: options.timeoutMs }),
357-      probeConductor(miniDirectBaseUrl, authHeaders, options.timeoutMs),
358-      fetchProbe(`${macDirectBaseUrl}/healthz`, { timeoutMs: options.timeoutMs }),
359-      probeConductor(macDirectBaseUrl, authHeaders, options.timeoutMs),
360-      probeStatusApi(miniStatusBaseUrl, options.timeoutMs),
361-      probeStatusApi(macStatusBaseUrl, options.timeoutMs)
362-    ]);
363-
364-  let controlState = null;
365-  if (!controlStateProbe.error && controlStateProbe.body) {
366-    try {
367-      controlState = normalizeControlState(JSON.parse(controlStateProbe.body));
368-    } catch (error) {
369-      controlStateProbe.parseError = error instanceof Error ? error.message : String(error);
370-    }
371-  }
372-
373-  const inferredLeader = options.expectLeader === "any" ? expectedLeaderFromControl(controlState) : options.expectLeader;
374-  const expectedLeader = inferredLeader || options.expectLeader;
375-  const expectedStandby = expectedLeader === "mini" ? "mac" : expectedLeader === "mac" ? "mini" : null;
376-
377-  const issues = [];
378-
379-  addIssue(issues, controlStateProbe.httpStatus === 200, `control-api /v1/system/state HTTP ${controlStateProbe.httpStatus ?? "error"}`);
380-  addIssue(issues, controlState?.ok === true, "control-api /v1/system/state did not return ok=true");
381-  addIssue(issues, responseMatches(publicConductor.healthz, 200, "ok"), "public /healthz did not return 200(ok)");
382-  addIssue(issues, responseMatches(publicConductor.readyz, 200, "ready"), "public /readyz did not return 200(ready)");
383-  addIssue(issues, responseMatches(publicConductor.rolez, 200, "leader"), "public /rolez did not return 200(leader)");
384-  addIssue(issues, miniNoAuth.httpStatus === 401, `mini direct no-auth /healthz expected 401, got ${miniNoAuth.httpStatus ?? "error"}`);
385-  addIssue(issues, macNoAuth.httpStatus === 401, `mac direct no-auth /healthz expected 401, got ${macNoAuth.httpStatus ?? "error"}`);
386-  addIssue(issues, responseMatches(miniDirect.healthz, 200, "ok"), "mini direct /healthz did not return 200(ok)");
387-  addIssue(issues, responseMatches(miniDirect.readyz, 200, "ready"), "mini direct /readyz did not return 200(ready)");
388-  addIssue(issues, responseMatches(macDirect.healthz, 200, "ok"), "mac direct /healthz did not return 200(ok)");
389-  addIssue(issues, responseMatches(macDirect.readyz, 200, "ready"), "mac direct /readyz did not return 200(ready)");
390-
391-  if (expectedLeader === "mini") {
392-    addIssue(issues, responseMatches(miniDirect.rolez, 200, "leader"), "mini direct /rolez did not return 200(leader)");
393-    addIssue(issues, responseMatches(macDirect.rolez, 200, "standby"), "mac direct /rolez did not return 200(standby)");
394-    addIssue(issues, controlState?.holderId?.startsWith("mini-") === true, `control-api holder_id did not point at mini: ${controlState?.holderId ?? "null"}`);
395-  } else if (expectedLeader === "mac") {
396-    addIssue(issues, responseMatches(macDirect.rolez, 200, "leader"), "mac direct /rolez did not return 200(leader)");
397-    addIssue(issues, responseMatches(miniDirect.rolez, 200, "standby"), "mini direct /rolez did not return 200(standby)");
398-    addIssue(issues, controlState?.holderId?.startsWith("mac-") === true, `control-api holder_id did not point at mac: ${controlState?.holderId ?? "null"}`);
399-  }
400-
401-  for (const [nodeName, statusProbe] of [
402-    ["mini", miniStatus],
403-    ["mac", macStatus]
404-  ]) {
405-    addIssue(issues, responseMatches(statusProbe.healthz, 200, "ok"), `${nodeName} status-api /healthz did not return 200(ok)`);
406-    addIssue(issues, statusProbe.status.httpStatus === 200, `${nodeName} status-api /v1/status HTTP ${statusProbe.status.httpStatus ?? "error"}`);
407-    addIssue(issues, statusProbe.summary?.ok === true, `${nodeName} status-api /v1/status did not return ok=true`);
408-    addIssue(
409-      issues,
410-      statusMatchesControl(controlState, statusProbe.summary),
411-      `${nodeName} status-api snapshot does not match control-api state`
412-    );
413-  }
414-
415-  const result = {
416-    ok: issues.length === 0,
417-    observedAt: new Date().toISOString(),
418-    inventory: {
419-      conductorHost: inventory.BAA_CONDUCTOR_HOST,
420-      envPath: options.envPath,
421-      macDirectHost: inventory.BAA_MAC_DIRECT_HOST,
422-      macTailscaleIp: inventory.BAA_MAC_TAILSCALE_IP,
423-      miniDirectHost: inventory.BAA_MINI_DIRECT_HOST,
424-      miniTailscaleIp: inventory.BAA_MINI_TAILSCALE_IP,
425-      publicIpv4: inventory.BAA_PUBLIC_IPV4 || null,
426-      proxies: {
427-        conductor: inventory.BAA_CF_PROXY_CONDUCTOR ?? null,
428-        mac: inventory.BAA_CF_PROXY_MAC ?? null,
429-        mini: inventory.BAA_CF_PROXY_MINI ?? null
430-      },
431-      statusApiPort,
432-      conductorPort
433-    },
434-    expectations: {
435-      expectedLeader,
436-      requestedExpectLeader: options.expectLeader,
437-      expectedStandby
438-    },
439-    controlApi: {
440-      baseUrl: controlApiBase,
441-      probe: controlStateProbe,
442-      state: controlState
443-    },
444-    conductors: {
445-      public: publicConductor,
446-      miniDirect: {
447-        noAuthHealthz: miniNoAuth,
448-        auth: miniDirect
449-      },
450-      macDirect: {
451-        noAuthHealthz: macNoAuth,
452-        auth: macDirect
453-      }
454-    },
455-    statusApis: {
456-      mini: miniStatus,
457-      mac: macStatus
458-    },
459-    issues
460-  };
461-
462-  const json = JSON.stringify(result, null, options.compact ? 0 : 2);
463-  process.stdout.write(`${json}\n`);
464-
465-  if (options.strict && !result.ok) {
466-    process.exit(1);
467-  }
468-}
469-
470-main().catch((error) => {
471-  const message = error instanceof Error ? error.message : String(error);
472-  process.stderr.write(`${basename(process.argv[1])}: ${message}\n`);
473-  process.exit(1);
474-});
D scripts/smoke/run-e2e.sh
+0, -6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" run "$@"
D scripts/smoke/stack-cli.mjs
+0, -831
  1@@ -1,831 +0,0 @@
  2-import { spawn } from "node:child_process";
  3-import { appendFileSync, closeSync, openSync } from "node:fs";
  4-import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
  5-import { createServer } from "node:net";
  6-import { dirname, join, resolve } from "node:path";
  7-import { setTimeout as delay } from "node:timers/promises";
  8-import { fileURLToPath } from "node:url";
  9-
 10-const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
 11-const DEFAULT_HOST = "127.0.0.1";
 12-const DEFAULT_LEASE_RENEW_INTERVAL_MS = 250;
 13-const DEFAULT_HEARTBEAT_INTERVAL_MS = 250;
 14-const DEFAULT_LEASE_TTL_SEC = 2;
 15-const HTTP_TIMEOUT_MS = 2_000;
 16-const START_TIMEOUT_MS = 15_000;
 17-const STOP_TIMEOUT_MS = 5_000;
 18-const POLL_INTERVAL_MS = 200;
 19-const PRIMARY_NODE_ID = "smoke-mini";
 20-const STANDBY_NODE_ID = "smoke-mac";
 21-
 22-async function main() {
 23-  const options = parseCli(process.argv.slice(2));
 24-
 25-  if (options.help) {
 26-    writeResult(options, renderHelp());
 27-    return;
 28-  }
 29-
 30-  switch (options.command) {
 31-    case "start": {
 32-      const state = await startStack(options);
 33-      writeResult(options, {
 34-        ok: true,
 35-        stateDir: state.stateDir,
 36-        services: summarizeStateServices(state)
 37-      });
 38-      return;
 39-    }
 40-
 41-    case "check": {
 42-      const state = await loadState(options);
 43-      const result = await captureLiveStack(state, {
 44-        expectedLeader: options.expectedLeader
 45-      });
 46-
 47-      writeResult(options, {
 48-        ok: true,
 49-        stateDir: state.stateDir,
 50-        ...result
 51-      });
 52-      return;
 53-    }
 54-
 55-    case "stop": {
 56-      const state = await loadState(options);
 57-      await stopStack(state);
 58-      writeResult(options, {
 59-        ok: true,
 60-        stateDir: state.stateDir,
 61-        stopped: true
 62-      });
 63-      return;
 64-    }
 65-
 66-    case "run": {
 67-      const result = await runSmokeFlow(options);
 68-      writeResult(options, result);
 69-      return;
 70-    }
 71-  }
 72-}
 73-
 74-function parseCli(argv) {
 75-  let command = "run";
 76-  let index = 0;
 77-
 78-  if (argv[0] && !argv[0].startsWith("--")) {
 79-    command = argv[0];
 80-    index = 1;
 81-  }
 82-
 83-  const options = {
 84-    command,
 85-    expectedLeader: null,
 86-    help: false,
 87-    json: false,
 88-    skipBuild: false,
 89-    stateDir: null
 90-  };
 91-
 92-  for (; index < argv.length; index += 1) {
 93-    const token = argv[index];
 94-
 95-    switch (token) {
 96-      case "--expected-leader":
 97-        options.expectedLeader = readOptionValue(argv, token, index);
 98-        index += 1;
 99-        break;
100-      case "--help":
101-      case "-h":
102-        options.help = true;
103-        break;
104-      case "--json":
105-        options.json = true;
106-        break;
107-      case "--skip-build":
108-        options.skipBuild = true;
109-        break;
110-      case "--state-dir":
111-        options.stateDir = resolve(readOptionValue(argv, token, index));
112-        index += 1;
113-        break;
114-      default:
115-        throw new Error(`Unknown smoke command option "${token}".`);
116-    }
117-  }
118-
119-  if (!["start", "check", "run", "stop"].includes(options.command)) {
120-    throw new Error(`Unknown smoke command "${options.command}".`);
121-  }
122-
123-  return options;
124-}
125-
126-function readOptionValue(argv, flag, index) {
127-  const value = argv[index + 1];
128-
129-  if (!value || value.startsWith("--")) {
130-    throw new Error(`Missing value for ${flag}.`);
131-  }
132-
133-  return value;
134-}
135-
136-async function runSmokeFlow(options) {
137-  const state = await startStack(options);
138-
139-  try {
140-    const initial = await captureLiveStack(state, {
141-      expectedLeader: PRIMARY_NODE_ID
142-    });
143-
144-    const drained = await verifyDrainAndResume(state);
145-    const task = await createSmokeTask(state.services.controlApi.baseUrl);
146-    const taskRecord = await readSmokeTask(state.services.controlApi.baseUrl, task.taskId);
147-    const afterQueue = await waitForQueueDepth(state.services.statusApi.baseUrl, 1);
148-
149-    await stopService(state, "primary");
150-
151-    const failover = await waitForFailover(state);
152-
153-    return {
154-      ok: true,
155-      stateDir: state.stateDir,
156-      initial,
157-      drained,
158-      task: {
159-        create: task.response,
160-        read: taskRecord,
161-        taskId: task.taskId
162-      },
163-      afterQueue,
164-      failover
165-    };
166-  } finally {
167-    await stopStack(state);
168-  }
169-}
170-
171-async function startStack(options) {
172-  const stateDir = options.stateDir ?? (await createStateDir());
173-  const state = {
174-    stateDir,
175-    stateFilePath: getStateFilePath(stateDir),
176-    dbPath: join(stateDir, "data", "control-plane.sqlite"),
177-    services: {}
178-  };
179-
180-  await mkdir(join(stateDir, "logs"), { recursive: true });
181-  await mkdir(join(stateDir, "runtime"), { recursive: true });
182-
183-  try {
184-    if (!options.skipBuild) {
185-      await buildDependencies();
186-    }
187-
188-    const ports = {
189-      controlApi: await allocatePort(),
190-      primary: await allocatePort(),
191-      standby: await allocatePort(),
192-      statusApi: await allocatePort()
193-    };
194-
195-    state.services.controlApi = await spawnService({
196-      args: [
197-        "scripts/smoke/control-api-local.mjs",
198-        "--db",
199-        state.dbPath,
200-        "--host",
201-        DEFAULT_HOST,
202-        "--port",
203-        String(ports.controlApi)
204-      ],
205-      baseUrl: `http://${DEFAULT_HOST}:${ports.controlApi}`,
206-      command: "node",
207-      logPath: join(stateDir, "logs", "control-api.log"),
208-      name: "control-api"
209-    });
210-    await writeState(state);
211-
212-    await waitForCondition(
213-      async () => {
214-        const envelope = await getJson(`${state.services.controlApi.baseUrl}/v1/system/state`);
215-        return envelope.ok ? envelope : null;
216-      },
217-      { description: "control-api readiness" }
218-    );
219-
220-    state.services.primary = await spawnConductorService(state, {
221-      host: "mini",
222-      localApiPort: ports.primary,
223-      name: "conductor-primary",
224-      nodeId: PRIMARY_NODE_ID,
225-      role: "primary"
226-    });
227-    await writeState(state);
228-
229-    state.services.standby = await spawnConductorService(state, {
230-      host: "mac",
231-      localApiPort: ports.standby,
232-      name: "conductor-standby",
233-      nodeId: STANDBY_NODE_ID,
234-      role: "standby"
235-    });
236-    await writeState(state);
237-
238-    state.services.statusApi = await spawnService({
239-      args: [
240-        "scripts/smoke/status-api-local.mjs",
241-        "--db",
242-        state.dbPath,
243-        "--host",
244-        DEFAULT_HOST,
245-        "--port",
246-        String(ports.statusApi)
247-      ],
248-      baseUrl: `http://${DEFAULT_HOST}:${ports.statusApi}`,
249-      command: "node",
250-      logPath: join(stateDir, "logs", "status-api.log"),
251-      name: "status-api"
252-    });
253-    await writeState(state);
254-
255-    await waitForCondition(
256-      async () => {
257-        const text = await getText(`${state.services.statusApi.baseUrl}/healthz`);
258-        return text === "ok" ? text : null;
259-      },
260-      { description: "status-api readiness" }
261-    );
262-
263-    await captureLiveStack(state, {
264-      expectedLeader: PRIMARY_NODE_ID
265-    });
266-
267-    return state;
268-  } catch (error) {
269-    await stopStack(state);
270-    throw error;
271-  }
272-}
273-
274-async function spawnConductorService(state, options) {
275-  const runtimeRoot = join(state.stateDir, "runtime", options.role);
276-
277-  for (const directoryName of ["logs", "runs", "state", "tmp", "worktrees"]) {
278-    await mkdir(join(runtimeRoot, directoryName), { recursive: true });
279-  }
280-
281-  const baseUrl = `http://${DEFAULT_HOST}:${options.localApiPort}`;
282-  const service = await spawnService({
283-    args: [
284-      "--experimental-strip-types",
285-      "apps/conductor-daemon/src/index.ts",
286-      "start",
287-      "--node-id",
288-      options.nodeId,
289-      "--host",
290-      options.host,
291-      "--role",
292-      options.role,
293-      "--control-api-base",
294-      state.services.controlApi.baseUrl,
295-      "--local-api",
296-      baseUrl,
297-      "--shared-token",
298-      "smoke-shared-token",
299-      "--heartbeat-interval-ms",
300-      String(DEFAULT_HEARTBEAT_INTERVAL_MS),
301-      "--lease-renew-interval-ms",
302-      String(DEFAULT_LEASE_RENEW_INTERVAL_MS),
303-      "--lease-ttl-sec",
304-      String(DEFAULT_LEASE_TTL_SEC),
305-      "--logs-dir",
306-      join(runtimeRoot, "logs"),
307-      "--runs-dir",
308-      join(runtimeRoot, "runs"),
309-      "--state-dir",
310-      join(runtimeRoot, "state"),
311-      "--tmp-dir",
312-      join(runtimeRoot, "tmp"),
313-      "--worktrees-dir",
314-      join(runtimeRoot, "worktrees")
315-    ],
316-    baseUrl,
317-    command: "node",
318-    logPath: join(state.stateDir, "logs", `${options.name}.log`),
319-    metadata: {
320-      host: options.host,
321-      nodeId: options.nodeId,
322-      role: options.role
323-    },
324-    name: options.name
325-  });
326-
327-  await waitForCondition(
328-    async () => {
329-      const text = await getText(`${baseUrl}/healthz`);
330-      return text === "ok" ? text : null;
331-    },
332-    { description: `${options.name} healthz` }
333-  );
334-
335-  return service;
336-}
337-
338-async function spawnService({ args, baseUrl, command, logPath, metadata = {}, name }) {
339-  appendFileSync(logPath, `\n$ ${command} ${args.join(" ")}\n`);
340-  const logFd = openSync(logPath, "a");
341-  const child = spawn(command, args, {
342-    cwd: REPO_ROOT,
343-    env: process.env,
344-    stdio: ["ignore", logFd, logFd]
345-  });
346-
347-  closeSync(logFd);
348-
349-  if (child.pid == null) {
350-    throw new Error(`Failed to start ${name}.`);
351-  }
352-
353-  return {
354-    ...metadata,
355-    baseUrl,
356-    logPath,
357-    name,
358-    pid: child.pid
359-  };
360-}
361-
362-async function captureLiveStack(state, { expectedLeader }) {
363-  const [controlState, primary, standby, statusSnapshot] = await Promise.all([
364-    readControlState(state.services.controlApi.baseUrl),
365-    readConductorSurface(state.services.primary.baseUrl),
366-    readConductorSurface(state.services.standby.baseUrl),
367-    readStatusSnapshot(state.services.statusApi.baseUrl)
368-  ]);
369-
370-  if (expectedLeader) {
371-    if (controlState.data.holder_id !== expectedLeader) {
372-      throw new Error(
373-        `Expected control-api leader to be ${expectedLeader}, received ${controlState.data.holder_id ?? "null"}.`
374-      );
375-    }
376-
377-    if (statusSnapshot.data.leaderId !== expectedLeader) {
378-      throw new Error(
379-        `Expected status-api leader to be ${expectedLeader}, received ${statusSnapshot.data.leaderId ?? "null"}.`
380-      );
381-    }
382-  }
383-
384-  if (primary.role !== "leader") {
385-    throw new Error(`Expected primary conductor rolez to be leader, received ${primary.role}.`);
386-  }
387-
388-  if (standby.role !== "standby") {
389-    throw new Error(`Expected standby conductor rolez to be standby, received ${standby.role}.`);
390-  }
391-
392-  return {
393-    controlState,
394-    conductors: {
395-      primary,
396-      standby
397-    },
398-    statusSnapshot
399-  };
400-}
401-
402-async function verifyDrainAndResume(state) {
403-  const drainedResponse = await postJson(`${state.services.controlApi.baseUrl}/v1/system/drain`, {
404-    reason: "verify shared state propagation",
405-    requested_by: "smoke-harness"
406-  });
407-
408-  if (!drainedResponse.ok) {
409-    throw new Error(`Expected drain request to succeed: ${JSON.stringify(drainedResponse)}`);
410-  }
411-
412-  const drainedStatus = await waitForCondition(
413-    async () => {
414-      const snapshot = await readStatusSnapshot(state.services.statusApi.baseUrl);
415-      return snapshot.data.mode === "draining" ? snapshot : null;
416-    },
417-    { description: "status-api draining mode" }
418-  );
419-  const drainedControlState = await readControlState(state.services.controlApi.baseUrl);
420-
421-  const resumedResponse = await postJson(`${state.services.controlApi.baseUrl}/v1/system/resume`, {
422-    reason: "continue smoke flow",
423-    requested_by: "smoke-harness"
424-  });
425-
426-  if (!resumedResponse.ok) {
427-    throw new Error(`Expected resume request to succeed: ${JSON.stringify(resumedResponse)}`);
428-  }
429-
430-  const resumedStatus = await waitForCondition(
431-    async () => {
432-      const snapshot = await readStatusSnapshot(state.services.statusApi.baseUrl);
433-      return snapshot.data.mode === "running" ? snapshot : null;
434-    },
435-    { description: "status-api running mode" }
436-  );
437-
438-  return {
439-    drained: {
440-      controlState: drainedControlState,
441-      statusSnapshot: drainedStatus
442-    },
443-    resumed: {
444-      controlState: await readControlState(state.services.controlApi.baseUrl),
445-      statusSnapshot: resumedStatus
446-    }
447-  };
448-}
449-
450-async function createSmokeTask(controlApiBaseUrl) {
451-  const response = await postJson(`${controlApiBaseUrl}/v1/tasks`, {
452-    acceptance: ["status-api queue depth reflects the queued smoke task"],
453-    goal: "Verify the local smoke harness can seed a queued task visible to status-api.",
454-    repo: REPO_ROOT,
455-    task_type: "smoke_harness",
456-    title: "Smoke harness queue probe"
457-  });
458-
459-  if (!response.ok) {
460-    throw new Error(`Expected task creation to succeed: ${JSON.stringify(response)}`);
461-  }
462-
463-  return {
464-    response,
465-    taskId: response.data.task_id
466-  };
467-}
468-
469-async function readSmokeTask(controlApiBaseUrl, taskId) {
470-  const response = await getJson(`${controlApiBaseUrl}/v1/tasks/${taskId}`);
471-
472-  if (!response.ok) {
473-    throw new Error(`Expected task read to succeed: ${JSON.stringify(response)}`);
474-  }
475-
476-  if (response.data.task_id !== taskId) {
477-    throw new Error(`Expected task read to return ${taskId}, received ${response.data.task_id}.`);
478-  }
479-
480-  if (response.data.status !== "queued") {
481-    throw new Error(`Expected task ${taskId} to be queued, received ${response.data.status}.`);
482-  }
483-
484-  return response;
485-}
486-
487-async function waitForQueueDepth(statusApiBaseUrl, minimumQueueDepth) {
488-  return waitForCondition(
489-    async () => {
490-      const snapshot = await readStatusSnapshot(statusApiBaseUrl);
491-      return snapshot.data.queueDepth >= minimumQueueDepth ? snapshot : null;
492-    },
493-    { description: `status-api queue depth >= ${minimumQueueDepth}` }
494-  );
495-}
496-
497-async function waitForFailover(state) {
498-  const standbyBaseUrl = state.services.standby.baseUrl;
499-  const controlApiBaseUrl = state.services.controlApi.baseUrl;
500-  const statusApiBaseUrl = state.services.statusApi.baseUrl;
501-
502-  return waitForCondition(
503-    async () => {
504-      const [controlState, standby, statusSnapshot] = await Promise.all([
505-        readControlState(controlApiBaseUrl),
506-        readConductorSurface(standbyBaseUrl),
507-        readStatusSnapshot(statusApiBaseUrl)
508-      ]);
509-
510-      if (
511-        controlState.data.holder_id !== STANDBY_NODE_ID ||
512-        statusSnapshot.data.leaderId !== STANDBY_NODE_ID ||
513-        standby.role !== "leader" ||
514-        standby.readyz !== "ready"
515-      ) {
516-        return null;
517-      }
518-
519-      return {
520-        controlState,
521-        conductors: {
522-          standby
523-        },
524-        statusSnapshot
525-      };
526-    },
527-    {
528-      description: "standby failover",
529-      timeoutMs: START_TIMEOUT_MS
530-    }
531-  );
532-}
533-
534-async function stopStack(state) {
535-  await stopService(state, "primary");
536-  await stopService(state, "standby");
537-  await stopService(state, "statusApi");
538-  await stopService(state, "controlApi");
539-  await writeState(state);
540-}
541-
542-async function stopService(state, key) {
543-  const service = state.services[key];
544-
545-  if (!service || service.stoppedAt) {
546-    return;
547-  }
548-
549-  if (!isProcessRunning(service.pid)) {
550-    service.stoppedAt = new Date().toISOString();
551-    return;
552-  }
553-
554-  process.kill(service.pid, "SIGTERM");
555-
556-  try {
557-    await waitForCondition(
558-      async () => (!isProcessRunning(service.pid) ? true : null),
559-      {
560-        description: `${service.name} shutdown`,
561-        intervalMs: 100,
562-        timeoutMs: STOP_TIMEOUT_MS
563-      }
564-    );
565-  } catch {
566-    if (isProcessRunning(service.pid)) {
567-      process.kill(service.pid, "SIGKILL");
568-      await waitForCondition(
569-        async () => (!isProcessRunning(service.pid) ? true : null),
570-        {
571-          description: `${service.name} forced shutdown`,
572-          intervalMs: 100,
573-          timeoutMs: STOP_TIMEOUT_MS
574-        }
575-      );
576-    }
577-  }
578-
579-  service.stoppedAt = new Date().toISOString();
580-}
581-
582-function isProcessRunning(pid) {
583-  if (pid == null) {
584-    return false;
585-  }
586-
587-  try {
588-    process.kill(pid, 0);
589-    return true;
590-  } catch (error) {
591-    return !(error instanceof Error) || !("code" in error) || error.code !== "ESRCH";
592-  }
593-}
594-
595-async function readControlState(controlApiBaseUrl) {
596-  const response = await getJson(`${controlApiBaseUrl}/v1/system/state`);
597-
598-  if (!response.ok) {
599-    throw new Error(`Expected control-api system state to succeed: ${JSON.stringify(response)}`);
600-  }
601-
602-  return response;
603-}
604-
605-async function readStatusSnapshot(statusApiBaseUrl) {
606-  const response = await getJson(`${statusApiBaseUrl}/v1/status`);
607-
608-  if (!response.ok) {
609-    throw new Error(`Expected status-api snapshot to succeed: ${JSON.stringify(response)}`);
610-  }
611-
612-  return response;
613-}
614-
615-async function readConductorSurface(conductorBaseUrl) {
616-  const [healthz, readyz, role, runtime] = await Promise.all([
617-    getText(`${conductorBaseUrl}/healthz`),
618-    getText(`${conductorBaseUrl}/readyz`),
619-    getText(`${conductorBaseUrl}/rolez`),
620-    getJson(`${conductorBaseUrl}/v1/runtime`)
621-  ]);
622-
623-  if (!runtime.ok) {
624-    throw new Error(`Expected conductor runtime probe to succeed: ${JSON.stringify(runtime)}`);
625-  }
626-
627-  return {
628-    healthz,
629-    readyz,
630-    role,
631-    runtime
632-  };
633-}
634-
635-async function buildDependencies() {
636-  await runCommand("npx", ["--yes", "pnpm", "--filter", "@baa-conductor/control-api-worker", "build"]);
637-  await runCommand("npx", ["--yes", "pnpm", "--filter", "@baa-conductor/status-api", "build"]);
638-}
639-
640-async function runCommand(command, args) {
641-  await new Promise((resolvePromise, reject) => {
642-    const child = spawn(command, args, {
643-      cwd: REPO_ROOT,
644-      env: process.env,
645-      stdio: ["ignore", "pipe", "pipe"]
646-    });
647-    let output = "";
648-
649-    child.stdout?.on("data", (chunk) => {
650-      output += chunk.toString();
651-    });
652-    child.stderr?.on("data", (chunk) => {
653-      output += chunk.toString();
654-    });
655-    child.on("error", reject);
656-    child.on("close", (code) => {
657-      if (code === 0) {
658-        resolvePromise();
659-        return;
660-      }
661-
662-      reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${output}`));
663-    });
664-  });
665-}
666-
667-async function waitForCondition(
668-  check,
669-  {
670-    description,
671-    intervalMs = POLL_INTERVAL_MS,
672-    timeoutMs = START_TIMEOUT_MS
673-  }
674-) {
675-  const deadline = Date.now() + timeoutMs;
676-  let lastError = null;
677-
678-  while (Date.now() <= deadline) {
679-    try {
680-      const result = await check();
681-
682-      if (result) {
683-        return result;
684-      }
685-    } catch (error) {
686-      lastError = error;
687-    }
688-
689-    await delay(intervalMs);
690-  }
691-
692-  const suffix =
693-    lastError == null
694-      ? ""
695-      : `: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
696-  throw new Error(`Timed out waiting for ${description}${suffix}`);
697-}
698-
699-async function getJson(url, init) {
700-  const response = await fetch(url, {
701-    ...init,
702-    headers: {
703-      "content-type": "application/json",
704-      ...(init?.headers ?? {})
705-    },
706-    signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
707-  });
708-  const text = await response.text();
709-
710-  try {
711-    return JSON.parse(text);
712-  } catch {
713-    throw new Error(`Expected JSON from ${url}, received status ${response.status}: ${text}`);
714-  }
715-}
716-
717-async function getText(url) {
718-  const response = await fetch(url, {
719-    signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
720-  });
721-  const text = (await response.text()).trim();
722-
723-  if (!response.ok) {
724-    throw new Error(`Expected ${url} to return 2xx, received ${response.status}: ${text}`);
725-  }
726-
727-  return text;
728-}
729-
730-async function postJson(url, body) {
731-  return getJson(url, {
732-    body: JSON.stringify(body),
733-    method: "POST"
734-  });
735-}
736-
737-async function allocatePort() {
738-  return new Promise((resolvePromise, reject) => {
739-    const server = createServer();
740-
741-    server.once("error", reject);
742-    server.listen(0, DEFAULT_HOST, () => {
743-      const address = server.address();
744-
745-      if (!address || typeof address === "string") {
746-        reject(new Error("Failed to allocate a TCP port."));
747-        return;
748-      }
749-
750-      server.close((error) => {
751-        if (error) {
752-          reject(error);
753-          return;
754-        }
755-
756-        resolvePromise(address.port);
757-      });
758-    });
759-  });
760-}
761-
762-async function createStateDir() {
763-  const tmpRoot = join(REPO_ROOT, "tmp");
764-  await mkdir(tmpRoot, { recursive: true });
765-  return mkdtemp(join(tmpRoot, "smoke-"));
766-}
767-
768-function getStateFilePath(stateDir) {
769-  return join(stateDir, "stack-state.json");
770-}
771-
772-async function writeState(state) {
773-  await writeFile(state.stateFilePath, JSON.stringify(state, null, 2));
774-}
775-
776-async function loadState(options) {
777-  const stateDir = options.stateDir;
778-
779-  if (!stateDir) {
780-    throw new Error("--state-dir is required for check and stop commands.");
781-  }
782-
783-  const content = await readFile(getStateFilePath(stateDir), "utf8");
784-  return JSON.parse(content);
785-}
786-
787-function summarizeStateServices(state) {
788-  return Object.fromEntries(
789-    Object.entries(state.services).map(([key, value]) => [
790-      key,
791-      {
792-        baseUrl: value.baseUrl,
793-        logPath: value.logPath,
794-        pid: value.pid
795-      }
796-    ])
797-  );
798-}
799-
800-function writeResult(options, value) {
801-  if (typeof value === "string") {
802-    process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
803-    return;
804-  }
805-
806-  process.stdout.write(`${JSON.stringify(value, null, options.json ? 2 : 2)}\n`);
807-}
808-
809-function renderHelp() {
810-  return [
811-    "Usage:",
812-    "  node scripts/smoke/stack-cli.mjs [run|start|check|stop] [options]",
813-    "",
814-    "Commands:",
815-    "  run                     Build, start, probe, create a queued task, and fail over to standby.",
816-    "  start                   Build and start the local smoke stack, then write stack-state.json.",
817-    "  check                   Probe an existing stack-state.json and print the current surface snapshot.",
818-    "  stop                    Stop the processes recorded in stack-state.json.",
819-    "",
820-    "Options:",
821-    "  --state-dir <path>      Reuse or write a specific smoke state directory.",
822-    "  --expected-leader <id>  Assert the current leader for the check command.",
823-    "  --skip-build            Skip the control-api/status-api build step.",
824-    "  --json                  Print JSON output.",
825-    "  --help, -h              Show this help text."
826-  ].join("\n");
827-}
828-
829-await main().catch((error) => {
830-  process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
831-  process.exitCode = 1;
832-});
D scripts/smoke/start-stack.sh
+0, -6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" start "$@"
D scripts/smoke/status-api-local.mjs
+0, -92
 1@@ -1,92 +0,0 @@
 2-import { D1StatusSnapshotLoader, startStatusApiServer } from "../../apps/status-api/dist/index.js";
 3-import { createSqliteD1Database } from "./d1-sqlite.mjs";
 4-
 5-function parseArgs(argv) {
 6-  const options = {
 7-    databasePath: null,
 8-    host: "127.0.0.1",
 9-    port: null
10-  };
11-
12-  for (let index = 0; index < argv.length; index += 1) {
13-    const token = argv[index];
14-
15-    switch (token) {
16-      case "--db":
17-        options.databasePath = readValue(argv, token, index);
18-        index += 1;
19-        break;
20-      case "--host":
21-        options.host = readValue(argv, token, index);
22-        index += 1;
23-        break;
24-      case "--port":
25-        options.port = parsePort(readValue(argv, token, index), token);
26-        index += 1;
27-        break;
28-      default:
29-        throw new Error(`Unknown status-api-local option "${token}".`);
30-    }
31-  }
32-
33-  if (!options.databasePath) {
34-    throw new Error("--db is required.");
35-  }
36-
37-  if (options.port == null) {
38-    throw new Error("--port is required.");
39-  }
40-
41-  return options;
42-}
43-
44-function readValue(tokens, flag, index) {
45-  const value = tokens[index + 1];
46-
47-  if (!value || value.startsWith("--")) {
48-    throw new Error(`Missing value for ${flag}.`);
49-  }
50-
51-  return value;
52-}
53-
54-function parsePort(value, flag) {
55-  const port = Number(value);
56-
57-  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
58-    throw new Error(`Invalid value for ${flag}: "${value}".`);
59-  }
60-
61-  return port;
62-}
63-
64-async function main() {
65-  const options = parseArgs(process.argv.slice(2));
66-  const db = createSqliteD1Database(options.databasePath);
67-  const server = await startStatusApiServer({
68-    host: options.host,
69-    port: options.port,
70-    snapshotLoader: new D1StatusSnapshotLoader(db)
71-  });
72-
73-  const close = async () => {
74-    await server.close();
75-    db.close();
76-  };
77-
78-  const shutdown = (signal) => {
79-    void close().finally(() => {
80-      process.exitCode = 0;
81-      if (signal) {
82-        process.stderr.write(`status-api-local stopped after ${signal}\n`);
83-      }
84-    });
85-  };
86-
87-  process.once("SIGINT", () => shutdown("SIGINT"));
88-  process.once("SIGTERM", () => shutdown("SIGTERM"));
89-
90-  process.stdout.write(`status-api-local listening on ${server.getBaseUrl()}\n`);
91-}
92-
93-await main();
D scripts/smoke/stop-stack.sh
+0, -6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" stop "$@"
D tests/e2e/smoke.test.mjs
+0, -46
 1@@ -1,46 +0,0 @@
 2-import assert from "node:assert/strict";
 3-import { execFile } from "node:child_process";
 4-import { rm } from "node:fs/promises";
 5-import { promisify } from "node:util";
 6-import { dirname, join, resolve } from "node:path";
 7-import test from "node:test";
 8-import { fileURLToPath } from "node:url";
 9-
10-const execFileAsync = promisify(execFile);
11-const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
12-
13-test("smoke harness starts the local stack, reflects queue state, and fails over to standby", async () => {
14-  const stateDir = join(REPO_ROOT, "tmp", `e2e-smoke-test-${Date.now()}`);
15-  let shouldCleanup = true;
16-
17-  try {
18-    const { stdout } = await execFileAsync(
19-      "bash",
20-      ["scripts/smoke/run-e2e.sh", "--json", "--state-dir", stateDir],
21-      {
22-        cwd: REPO_ROOT,
23-        maxBuffer: 10 * 1024 * 1024,
24-        timeout: 120_000
25-      }
26-    );
27-    const result = JSON.parse(stdout);
28-
29-    assert.equal(result.ok, true);
30-    assert.equal(result.initial.controlState.data.holder_id, "smoke-mini");
31-    assert.equal(result.initial.statusSnapshot.data.leaderId, "smoke-mini");
32-    assert.equal(result.drained.drained.statusSnapshot.data.mode, "draining");
33-    assert.equal(result.drained.resumed.statusSnapshot.data.mode, "running");
34-    assert.equal(result.task.read.data.status, "queued");
35-    assert.equal(result.afterQueue.data.queueDepth, 1);
36-    assert.equal(result.failover.controlState.data.holder_id, "smoke-mac");
37-    assert.equal(result.failover.statusSnapshot.data.leaderId, "smoke-mac");
38-    assert.equal(result.failover.conductors.standby.role, "leader");
39-  } catch (error) {
40-    shouldCleanup = false;
41-    throw error;
42-  } finally {
43-    if (shouldCleanup) {
44-      await rm(stateDir, { force: true, recursive: true });
45-    }
46-  }
47-});