11 KiB
Proposal C2: service-graph LateBinding 替换为 RuntimeBus(事件总线)
来源:问题.md C2(行 129-145)、附录 F 样本 6(行 511-530) 状态:proposed(待 B4 完成后实施) 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs) Owner: TBD 估计工时:3 周 Blocking dependencies:B4(single-writer actor 先稳定并发模型)
问题陈述
src/main/service-graph.ts:110-113 存在 4 个 LateBinding 占位符:
const orchestrationRef = new LateBinding<...>("orchestrationService");
const automationRef = new LateBinding<...>("automationService");
const browserActionRef = new LateBinding<...>("browserActionService");
const updaterRef = new LateBinding<...>("updaterService");
注释里已经承认这是为了解决循环依赖:
"worker needs orchestration (for dispatchRunToWorker), orchestration needs worker (for enqueueWorkerRun), orchestration needs automation (for dispatchAutomation), automation/orchestration need browser-action…"
LateBinding.get() 在未绑定时抛异常——启动顺序错一步整棚塌,且 runtime 无法静态验证调用安全。
进一步证据(src/main/service-graph.ts:174-191 的 RuntimeMessageInjector 构造):
runtimeMessageInjector = new RuntimeMessageInjector({
db,
runtimeBridge: piRuntimeRef.current.runtimeBridge,
postToWorker: (message) => {
const worker = workerService?.getWorker();
if (!worker) throw new Error("Worker is not initialised — cannot deliver steer message.");
worker.postMessage(message);
},
getWakeAgent: () => {
if (!orchestrationRef.isAssigned()) return null;
const orch = orchestrationRef.get();
return (agentId, companyId, trigger) => orch.wakeAgentIfPossible(agentId, companyId, trigger);
},
logger,
});
两个 callback(postToWorker / getWakeAgent)每次调用都要重新检查 binding 状态——这不是依赖注入,是运行时祈祷。
根因分析
Linus 视角(好品味原则第一条 + 第七条):循环依赖不能靠 lazy 解决,说明模块边界切错了。
4 个服务互相咬尾巴:
worker ──dispatchRun──> orchestration
orchestration ──enqueueRun──> worker
orchestration ──dispatchAutomation──> automation
automation ──wakeAgent──> orchestration
automation ──dispatchAction──> browserAction
browserAction ──wakeAgent──> orchestration
这不是 4 个服务,这是一个分布式系统被错误地拆成 4 个互相调用的同步对象。真正的解法是反转依赖方向——让所有服务都依赖一个事件/命令总线,服务之间不直接持有彼此的引用。
LateBinding 的问题:
- 编译期错误 → 运行时错误:bind 错了、漏 bind、bind 顺序错,TypeScript 编译全绿,应用启动崩
- init 顺序依赖人工维护:
service-graph.ts:120-265的set()顺序是 review 出来的,新增服务必须重新排雷 - 调用位置静态不可达:搜
dispatchRunToWorker的消费者只能 grep 到.get().dispatchRunToWorker,IDE rename 无法跟踪 - 测试无法 mock:注入一个假 LateBinding 要复制整个 binding 容器,测试设置成本高
设计方案
RuntimeBus 接口
// src/main/runtime/runtime-bus.ts
export interface RuntimeBus {
// 命令投递(fire-and-forget 或返回 ack Promise)
dispatch<K extends CommandKind>(kind: K, payload: CommandPayload<K>): Promise<CommandAck<K>>;
// 订阅特定命令类型(service 注册自己能处理什么)
subscribe<K extends CommandKind>(kind: K, handler: CommandHandler<K>): () => void;
// 事件广播(1 对 N,无 ack)
emit<K extends EventKind>(kind: K, payload: EventPayload<K>): void;
// 事件订阅
on<K extends EventKind>(kind: K, listener: EventListener<K>): () => void;
}
命令类型表(强类型 contract)
export type CommandKind =
| "run.dispatch" // worker ← orchestration
| "run.enqueue" // orchestration ← worker
| "agent.wake" // orchestration ← automation/browserAction/...
| "automation.dispatch" // automation ← orchestration
| "browserAction.dispatch"// browserAction ← automation/orchestration
| "steer.postToWorker" // worker ← runtimeMessageInjector
| "updater.check"; // updater ← main/menu
export interface CommandPayloadMap {
"run.dispatch": { runId: string; agentId: string; companyId: string };
"run.enqueue": { runId: string; ... };
"agent.wake": { agentId: string; companyId: string; trigger: AgentTrigger };
"automation.dispatch": { ruleId: string; ... };
"browserAction.dispatch": { actionId: string; ... };
"steer.postToWorker": { message: WorkerInboundMessage };
"updater.check": { manual: boolean };
}
服务改造
Before(service-graph.ts:174-191):
runtimeMessageInjector = new RuntimeMessageInjector({
db,
postToWorker: (msg) => workerService?.getWorker()?.postMessage(msg),
getWakeAgent: () => orchestrationRef.isAssigned() ? orchestrationRef.get().wakeAgentIfPossible : null,
...
});
After:
runtimeMessageInjector = new RuntimeMessageInjector({ db, bus, logger });
// RuntimeMessageInjector 内部:
async steer(runId: string, message: string) {
await this.bus.dispatch("steer.postToWorker", { message: { ... } });
}
async wakeAgent(agentId: string, companyId: string, trigger: AgentTrigger) {
await this.bus.dispatch("agent.wake", { agentId, companyId, trigger });
}
服务注册
// service-graph.ts(重写后,清晰 linear init)
const bus = new InProcessRuntimeBus();
const workerService = new WorkerService({ db, bus, ... });
workerService.register(); // 内部订阅 "run.dispatch", "steer.postToWorker"
const orchestrationService = new OrchestrationService({ db, bus, ... });
orchestrationService.register(); // 订阅 "run.enqueue", "agent.wake"
const automationService = new AutomationService({ db, bus, ... });
automationService.register(); // 订阅 "automation.dispatch"
const browserActionService = new BrowserActionService({ db, bus, ... });
browserActionService.register(); // 订阅 "browserAction.dispatch"
// 所有服务已注册,可以开始 dispatch
await orchestrationService.start();
没有 LateBinding。没有回调地狱。没有"isAssigned 检查"。注册完 = 可用。
InProcessRuntimeBus 实现
export class InProcessRuntimeBus implements RuntimeBus {
private handlers = new Map<CommandKind, CommandHandler<CommandKind>>();
private listeners = new Map<EventKind, Set<EventListener<EventKind>>>();
subscribe<K>(kind: K, handler: CommandHandler<K>): () => void {
if (this.handlers.has(kind)) {
throw new Error(`Command kind "${kind}" already has a handler — commands are 1:1`);
}
this.handlers.set(kind, handler);
return () => this.handlers.delete(kind);
}
async dispatch<K>(kind: K, payload: CommandPayload<K>): Promise<CommandAck<K>> {
const handler = this.handlers.get(kind);
if (!handler) {
throw new Error(`No handler registered for "${kind}"`);
}
return await handler(payload);
}
// emit/on 类似
}
关键约束:dispatch 要求 handler 必须存在(不像 LateBinding 可以延迟到调用时 crash,这里是 register 完就保证可用)。
Migration 计划
- Phase 1(1 周):新建
src/main/runtime/runtime-bus.ts+ 类型表,不动现有代码。 - Phase 2(1 周):一个服务一个服务地迁移到 bus:
- 先迁 worker ↔ orchestration(最核心的循环)
- 再迁 automation
- 最后迁 browserAction 和 updater 每次迁移一个服务,保留 LateBinding 直到全部迁完。
- Phase 3(3 天):迁完后删
LateBinding类 +service-graph.ts:110-113的 ref 段落 + 所有.get()/.isAssigned()调用。 - Phase 4(2 天):把 service-graph.ts 从 284 行的 callback 森林重写成 linear init(预计 < 100 行)。
- Phase 5(持续):新增服务必须通过 bus 交互,ESLint rule 禁止直接 import 另一个服务类。
风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|---|---|---|
| 性能回退 | bus dispatch 多一次 async hop | 可能的 10μs 级开销,远低于 SQLite 写本身;不可接受时改为 sync dispatch(subscribe 仍是 sync handler) |
| 类型体操复杂 | CommandPayloadMap 泛型推导 | 类型系统验证过类似 pattern(Redux toolkit 的 PayloadAction),成熟方案 |
| 初始化循环未发现 | 某服务订阅 + 自己 dispatch 同一 kind | bus 内部加检测:handler 执行时记 call stack,发现 reentrant 直接抛 |
| 测试 mock 成本 | 需要 mock bus | 反而更容易——测试用 new InProcessRuntimeBus() 即可,精确订阅需要的 kind |
回滚路径:phase 2 每个服务迁移是独立 PR,任何一个发现问题可 revert;phase 3 前 LateBinding 仍在,语义层面完全兼容。
验证标准
- Typecheck:CommandKind ↔ Payload 类型约束强制 compile-time 正确
- Linear init:
service-graph.ts行数 < 100,无 LateBinding / 无 callback / 无 isAssigned - 契约测试:每个 CommandKind 配 fixture,验证 payload shape 稳定
- 集成测试:启动顺序随机化 N 次,应用都能正常起(验证无启动顺序依赖)
- Static scan:
grep -rn "LateBinding\|isAssigned" src/无命中
不在范围
- 不做跨进程 bus:renderer/main 之间继续走 IPC,bus 只管 main 进程内部。
- 不做 event sourcing:bus 不持久化 event history,任何需要审计的事件仍走 DB。
- 不改 pi runtime 对接:pi runtime 仍走 piRuntimeRef 方式,本 proposal 只动 4 个自研服务。
- 不引入 RxJS / reactive library:简单 EventEmitter 模式足够。
- 不做 priority queue:所有 command 同等优先级;需要 backpressure 走 B4 的 writer actor。
参考文件清单
src/main/service-graph.ts:110-113(4 个 LateBinding)src/main/service-graph.ts:120-265(人工排序的 init)src/main/service-graph.ts:174-191(RuntimeMessageInjector callback 地狱)src/main/service-graph.ts:258-263(LateBinding set 段落)src/main/orchestration/orchestration-service.ts(消费者)src/main/worker/worker-runner.ts(消费者)src/main/automation-service.ts(消费者)src/main/browser/browser-action-service.ts(消费者)
附:为什么不是现在立刻修
B4(single-writer actor)必须先做——否则 bus dispatch 内部触发数据库写时会继续多 writer 抢锁,C2 的改进被淹没。先把"谁写 DB"的契约收敛,再解决"谁叫谁"的契约。
C2 本身是**"让下一个 AI 能读懂这个项目"**的前提——现在的 service-graph.ts 是 AI coding agent 最容易读错的文件之一(circular reference + lazy binding + callback 三重混淆)。修完后,新 feature 接入服务的时间从"读半天 graph 再写"降到"开一个 subscribe"。
Steve Jobs 视角:LateBinding 是"技术上可行但用起来很差"的典型——它让架构师觉得自己聪明,让所有后来者困惑。好的设计应该让无需理解 init 顺序就能加新服务。
附:gating matrix
| Gate | 条件 | 放行判断人 |
|---|---|---|
| G1 | B4 single-writer actor 完成 | Linus |
| G2 | RuntimeBus 接口 + CommandKind 枚举固化 | Linus |
| G3 | 4 个循环服务全部迁移到 bus | Owner |
| G4 | service-graph.ts 行数 < 100 | Linus |
| G5 | 启动顺序随机化测试 N 次全绿 | Owner |