Files
AgentCompany/docs/proposals/C2-service-graph-runtime-bus.md
T

11 KiB
Raw Blame History

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 的问题:

  1. 编译期错误 → 运行时错误:bind 错了、漏 bind、bind 顺序错,TypeScript 编译全绿,应用启动崩
  2. init 顺序依赖人工维护:service-graph.ts:120-265 的 set() 顺序是 review 出来的,新增服务必须重新排雷
  3. 调用位置静态不可达:搜 dispatchRunToWorker 的消费者只能 grep 到 .get().dispatchRunToWorker,IDE rename 无法跟踪
  4. 测试无法 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 计划

  1. Phase 1(1 周):新建 src/main/runtime/runtime-bus.ts + 类型表,不动现有代码。
  2. Phase 2(1 周):一个服务一个服务地迁移到 bus:
    • 先迁 worker ↔ orchestration(最核心的循环)
    • 再迁 automation
    • 最后迁 browserAction 和 updater 每次迁移一个服务,保留 LateBinding 直到全部迁完。
  3. Phase 3(3 天):迁完后删 LateBinding 类 + service-graph.ts:110-113 的 ref 段落 + 所有 .get() / .isAssigned() 调用。
  4. Phase 4(2 天):把 service-graph.ts 从 284 行的 callback 森林重写成 linear init(预计 < 100 行)。
  5. 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 仍在,语义层面完全兼容。

验证标准

  1. Typecheck:CommandKind ↔ Payload 类型约束强制 compile-time 正确
  2. Linear init:service-graph.ts 行数 < 100,无 LateBinding / 无 callback / 无 isAssigned
  3. 契约测试:每个 CommandKind 配 fixture,验证 payload shape 稳定
  4. 集成测试:启动顺序随机化 N 次,应用都能正常起(验证无启动顺序依赖)
  5. 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