Add proposals for deferred root-cause refactors (B2/B4/C1/C2/C4/C7/M2/M5/M7-M12)

This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 10:53:16 -04:00
1 parent 77076e7b29
commit 06d58d9fd6
15 files changed
+3660

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ oldreference/
*.md
!/README.md
!/docs/*.md
!/docs/proposals/*.md
!/CLAUDE.md
!/.planning/**/*.md
!/src/main/prompt-builder/**/*.md
+256
View File
@@ -0,0 +1,256 @@
# Proposal B2: tasks 状态机重构为 phase enum + task_leases 表
> 来源:问题.md B2(行 45-64)、附录 F 样本 1(行 424-441)、样本 2(行 444-457)
> 状态:proposed(待 B5 eval harness 就绪后实施)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:3-4 周(与 C1 共享 migration 窗口)
> Blocking dependencies:B1(pi 版本收敛,已完成)、B5(eval harness 建立,仍 blocked by user constraint)
## 问题陈述
单张 `tasks` 表并行持有 5 个锁/状态字段,彼此之间没有代数约束:
- `status`(枚举:backlog / todo / in_progress / done / blocked / …)
- `active_run_id`(当前运行的 run id)
- `checkout_run_id`(正在抢占执行权的 run id)
- `execution_locked_at`(执行锁 timestamp)
- `active_session_id`(pi agent session id)
这 5 列的合法组合本应由状态机约束,实际由散落在 10+ 处的 SQL 片段手动维护。`src/main/database/tasks-status.ts:160-189` 的 `checkoutTask` 函数 CAS 条件里已经被迫加了补丁分支:
```sql
AND (
status IN ('todo', 'backlog')
OR (status = 'in_progress' AND active_run_id IS NULL)
)
```
这个 `OR (status = 'in_progress' AND active_run_id IS NULL)` 分支是"从 orphan 状态恢复"的补丁。代码注释自己承认:
> "recovers from a specific orphan: finishRun has cleared active_run_id and checkout_run_id but the status transition never fired … without the reclaim, heartbeat picks the task and then loops on `checkout_race_lost` forever"
进一步证据:
- `src/main/database-base.ts:369-387` 的 `interruptStaleRuns`:只清 `active_run_id`、把 agents 设为 idle、释放 workspace lock。**没清** `checkout_run_id` 和 `execution_locked_at`。
- `src/main/database-base.ts:389-400` 的 `recoverStaleLocks`:用 15 分钟 cutoff 清 `checkout_run_id` 和 `execution_locked_at`。
- 两条独立路径各写各的字段,顺序错位就出现「status=in_progress,active_run_id=null,checkout_run_id=null」的 zombie。
## 根因分析
Linus 视角(好品味原则第一条):"特殊情况就是你数据结构错了"。
当前设计把"执行权 lease"的元数据打散到 tasks 表的多个列里,违反了**最基本的关系代数**:tasks 是实体,执行权是有起止时间的租约,这是两个有独立生命周期的东西。把它们塞在同一行就必然出现:
1. **部分更新问题**:清 lease 时必须同时清 `active_run_id` / `checkout_run_id` / `execution_locked_at`,任何一处漏掉就是 zombie。
2. **状态组合爆炸**:5 个字段理论组合数是 5! = 120 种(真实合法的只有 ~6 种),SQL 层无法约束。
3. **多 writer 竞态**:`interruptStaleRuns` 和 `recoverStaleLocks` 各自只负责一部分字段,顺序依赖靠 code review 维持。
4. **补丁放大效应**:`checkoutTask` 的 OR 分支注释里写得很清楚——"如果不补,heartbeat 就会永远循环 checkout_race_lost"。这不是"修",这是给错的数据结构打绷带。
问题的根源是:**tasks 表承担了两个职责**——"我是什么任务"(entity)和"我现在被谁锁着"(lease)。这两个职责必须拆开。
## 设计方案
### 新 schema
```sql
-- 主表:只保留任务本身的语义
ALTER TABLE tasks ADD COLUMN phase TEXT NOT NULL
CHECK (phase IN ('backlog', 'ready', 'running', 'awaiting_review', 'done', 'blocked'));
-- 删除:active_run_id, checkout_run_id, execution_locked_at, active_session_id, status(等迁移完成)
-- 新表:租约独立生命周期
CREATE TABLE task_leases (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT NOT NULL REFERENCES runs(id),
acquired_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
session_id TEXT,
-- 显式外键 + 唯一约束,SQL 层强制"一个 task 同时只能有一个 lease"
UNIQUE (task_id)
);
CREATE INDEX idx_task_leases_expires ON task_leases(expires_at);
```
### 状态机语义
| phase | 合法来源 phase | lease 是否存在 | 触发条件 |
|------|---------|---------|---------|
| `backlog` | (init) / `blocked` 清除 | 否 | 任务初建 |
| `ready` | `backlog` | 否 | 需求就绪 |
| `running` | `ready` | **必须有** | `checkoutTask` 成功 |
| `awaiting_review` | `running` | 否(finishRun 释放) | deliverable 产出 |
| `done` | `awaiting_review` | 否 | 审核通过 |
| `blocked` | 任何(除 done) | 否 | 显式 block |
**关键不变式**:`phase = 'running'` ⇔ `task_leases` 存在对应行。SQL 层面用 trigger 强制。
### 单一状态迁移函数
```ts
// src/main/database/tasks-status.ts(重写)
export function advanceTaskPhase(
taskId: string,
from: TaskPhase,
to: TaskPhase,
ctx: { runId?: string; sessionId?: string; ts?: number }
): { ok: true } | { ok: false; reason: string } {
return db.transaction(() => {
// 1. CAS 写 tasks.phase
// 2. 根据 from/to 组合插入/删除 task_leases 行
// 3. 违反不变式直接 throw(let it crash)
})();
}
```
**所有**状态迁移都走这一个函数,禁止直接 `UPDATE tasks SET status = ...`。改造 call sites:
- `src/main/database/tasks-status.ts`(checkoutTask, finishRun, interruptRun)
- `src/main/database-base.ts:369-400`(interruptStaleRuns, recoverStaleLocks)
- `src/main/orchestration/scheduler.ts`、`task-runner.ts`、`heartbeat.ts` 的所有 tasks 写路径
### 删除 OR 补丁
重构后,`checkoutTask` 变成:
```sql
UPDATE tasks SET phase = 'running' WHERE id = ? AND phase = 'ready';
INSERT INTO task_leases (task_id, run_id, acquired_at, expires_at, session_id) VALUES (?, ?, ?, ?, ?);
```
没有 OR,没有 orphan。如果 `interruptRun` 漏清 lease,trigger 会在下次 advanceTaskPhase 时直接报 constraint violation——**让它 crash,不掩盖**。
## Migration 计划
1. **v49 migration**(dual-write 阶段,1 周)
- 新建 `task_leases` 表(空)
- 新增 `tasks.phase` 列,默认值从 `status` 映射:`backlog→backlog, todo→ready, in_progress+active_run_id→running, …`
- 应用层所有写路径同时写 `phase` 和旧 `status`,写 lease 同时写 `active_run_id`/`checkout_run_id`
2. **backfill**(自动)
- v49 启动时一次性 SQL:根据旧 5 列反推 phase + 插入 lease 行
- 写 validation:phase = running ⇔ lease 存在;不成立直接 crash(暴露现存 orphan)
3. **v50 switch-read**(2 周后)
- 所有读路径切到 `phase` + `task_leases` JOIN
- 确认 telemetry 没有新 404/500
4. **v51 drop-old**(4 周后)
- 删 `active_run_id`、`checkout_run_id`、`execution_locked_at`、`active_session_id`、`status` 列
- 删 `checkoutTask` 的 OR 分支(届时代码里已不存在)
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| backfill 发现现存 orphan | v49 启动时 assertion fail | v49 migration 提供 `--heal-orphans` flag,根据最后活跃 run 推断正确 phase |
| dual-write 导致写冲突 | 并发 advanceTaskPhase vs 旧路径 | v49 → v50 之间禁止 `UPDATE tasks SET status = ...`(ESLint rule + SQL trigger 拦截) |
| switch-read 性能回退 | JOIN 开销增加 | 给 `task_leases(task_id)` 建 UNIQUE 索引(schema 已含) |
| 测试 fixture 不同步 | tests/unit 里的 stub 数据没 phase 列 | 所有测试 fixture 走 `makeTaskRecord()` helper 统一生成 |
**真回滚路径**:v51 前每一步都可降级——任何时候发现新 schema 有 bug,停止应用层双写、所有读切回旧列,v49 migration 可以通过删 `task_leases` 表 + 清 `phase` 列回到原状态。
## 验证标准
**前置依赖:B5 eval harness 必须先就绪**(Karpathy 强烈反对无 eval 下做状态机改动)。
具体验证:
1. **契约测试**:构造 30+ 种非法状态组合,验证 `advanceTaskPhase` 全部拒绝
2. **并发 fuzz**:2 个 worker 同时 checkoutTask 同一个 task,只有一个成功
3. **崩溃恢复**:kill -9 worker,下次启动 `recoverStaleLocks` 能清掉过期 lease
4. **行为 eval**:相同 company 输入,v48 和 v51 产出的 run trace 在 agent 行为层一致(tool calls / state transitions 序列)
5. **Telemetry 指标**:生产环境 `checkout_race_lost` 发生率不增,`sqlite_busy` 不增
## 不在范围
- **不重构 runs 表**:runs 的 status 机自身健康,本 proposal 只动 tasks。
- **不改 IPC 契约**:renderer 看到的仍是 `TaskRecord.status`,由 map 层从 phase/lease 合成。
- **不做 per-agent quota**:执行权限流控是另一件事,走独立 proposal。
- **不引入分布式锁**:SQLite 单 writer 前提不变,B4 的 single-writer actor 是独立 proposal。
- **不做 phase transition 审计 log**:需要的话另开 event 表,和本 proposal 解耦。
## 参考文件清单
- `src/main/database/tasks-status.ts:160-189`(checkoutTask + OR 补丁)
- `src/main/database-base.ts:369-387`(interruptStaleRuns 只清一半字段)
- `src/main/database-base.ts:389-400`(recoverStaleLocks 另一半字段)
- `src/main/database-base.ts:49-51`(作者自白 pass-through 架构)
- `src/main/database/migrations/`(48 个既有 migration 作为 v49/v50/v51 的 template)
- `src/main/orchestration/scheduler.ts`(heartbeat 写路径)
- `src/main/worker/worker-runner.ts:239` 附近(worker 回写 run 状态)
## 附:为什么现在修而不是等
`checkoutTask` 的 OR 补丁在生产环境就是**一个 agent 完全卡死并持续烧钱**——每次 heartbeat 唤醒都重新尝试 checkout,每次失败产生日志 / 事件 / cost 记录。数据结构错误导致 agent 静默陷入死循环,这是最糟糕的一类 bug:不 crash,不报错,只是不前进。用户付钱看着 agent 原地踏步。
每多拖一周,新 call site 就多一批——数据结构错误会 viral 扩散。把 5 列乱象标准化后,后续所有 feature(new phase、priority、deadline)都受益。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B5 eval harness 可以 record baseline behavior | Roundtable |
| G2 | v49 dual-write 测试环境跑 48 小时无 assertion fail | Owner |
| G3 | v50 switch-read 灰度 1 个 company 7 天 | Owner |
| G4 | v51 drop-old 前所有 call site 静态 scan 无 `status IN ('todo', 'backlog')` | Linus |
| G5 | `checkoutTask` OR 分支删除后 telemetry `checkout_race_lost` 连续 14 天为 0 | Karpathy |
## 附:与其他 proposal 的耦合
| 相关 | 关系 |
|------|-----|
| B1(pi 版本 pin) | 已完成 |
| B4(single-writer actor) | 互补:B4 收敛"谁能写",B2 收敛"写什么语义" |
| C1(AppDatabase pass-through) | 共享 migration 窗口:两者都需要改 DB 访问 call site,codemod 可同时改 |
| C6(error taxonomy) | B2 的 let-it-crash 依赖 C6 的 error 分类来决定 crash vs retry |
## 附:反模式对比(before / after)
**Before**(`src/main/database/tasks-status.ts:160-189`):
```sql
UPDATE tasks
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ?
WHERE id = ?
AND checkout_run_id IS NULL
AND (
status IN ('todo', 'backlog')
OR (status = 'in_progress' AND active_run_id IS NULL) -- orphan 恢复补丁
);
```
**After**(B2 完成后):
```sql
-- 原子操作 1:phase 转移
UPDATE tasks SET phase = 'running' WHERE id = ? AND phase = 'ready';
-- 原子操作 2:插入 lease(UNIQUE 约束防止重复)
INSERT INTO task_leases (task_id, run_id, acquired_at, expires_at, session_id) VALUES (?, ?, ?, ?, ?);
```
两个 statement 走一个 transaction;UNIQUE(task_id) 约束让"同一个 task 同时只有一个 lease"成为 SQL 层不变式,无需应用层 CAS。
## 附:涉及 SQL 片段清单(待改造)
- `src/main/database/tasks-status.ts:160-189` checkoutTask
- `src/main/database/tasks-status.ts` finishRun(释放 lease)
- `src/main/database-base.ts:369-387` interruptStaleRuns
- `src/main/database-base.ts:389-400` recoverStaleLocks
- `src/main/orchestration/scheduler.ts` 的 wake writer
- `src/main/orchestration/heartbeat.ts` markHeartbeatAt(touch lease.expires_at)
- `src/main/worker/worker-runner.ts` 的 finishRun 回调
每处都从"直接 UPDATE 5 列"改为"调 `advanceTaskPhase`"。改造 PR 按文件拆,每个 PR 跑 typecheck + 单测。
## 附:为什么不选其他方案
考虑过的替代设计:
**方案 A(直接拆 leases 表)**:当前 proposal 采用。优点:SQL 层可强约束;缺点:需要 dual-write migration。
**方案 B(保留 5 列但用 check constraint)**:SQLite check constraint 表达力有限,无法精确表达"running 时 active_run_id 非空且 checkout_run_id 也非空"这类跨列约束。
**方案 C(单独一个 state machine 中间层 service)**:应用层约束比 SQL 层约束弱——任何代码绕过 service 直接 SQL 就失效。
方案 A 是唯一把约束放在数据结构层的。
## 附:phase 枚举外的状态跟踪
某些运行态信息仍需要但不属于 phase 枚举:
- `blocked_reason`(为什么 block)
- `awaiting_review_since`(等待审核已多久)
- `attempted_count`(重试次数)
这些存在 tasks 的辅助列或独立表——独立于 phase enum,不参与 CAS。
+252
View File
@@ -0,0 +1,252 @@
# Proposal B4: 所有 mutation 集中到 single-writer actor(main 进程串行 queue)
> 来源:问题.md B4(行 83-94)、附录 G 第 5 行(行 559)
> 状态:proposed(待 B2 完成后实施)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:2 周
> Blocking dependencies:B2(phase/lease 重构先稳住数据结构)
## 问题陈述
项目只有一个 `DatabaseSync` 连接(`src/main/database-base.ts` 的 `openConnection` 在 main 进程初始化一次),但并发 writer 至少有 4 类:
1. **Scheduler 周期唤醒**(`src/main/orchestration/scheduler.ts`、`scheduler-heartbeat.ts`)
2. **Task runner dispatch**(`src/main/orchestration/task-runner.ts`)
3. **Heartbeat 周期写**(`src/main/orchestration/heartbeat.ts` 的 `markHeartbeatAt`)
4. **Worker 回调 run 状态**(`src/main/worker/worker-runner.ts`)
5. **Automation service 触发**(`src/main/automation-service.ts`)
6. **Backup 任务**(`src/main/database-backup.ts` 或类似)
所有 writer 都走 `transaction(BEGIN IMMEDIATE)`。`src/main/database-base.ts:433-440` 的连接配置只有 `busy_timeout=5000`,`database-base.ts:442-452` 的 `transaction` 包装**没有任何重试**。
后果:
- 高压场景下 `BEGIN IMMEDIATE` 会互相串行等 5 秒
- 一旦串行等待链超过 5 秒,事务被 `SQLITE_BUSY` 反弹
- 对调用方:可能表现为"事务成功"但实际回滚,或抛 raw `SQLITE_BUSY` 没人 catch
- 线上没有 `SQLITE_BUSY` 计数 telemetry,问题完全不可观测
## 根因分析
Linus 视角:**SQLite 不是并发数据库**。WAL 模式下读可以并发,写永远只有一个。这不是 bug,是 SQLite 的 design goal。
当前项目的错误是:**把 SQLite 当 Postgres 用**——4+ 个异步来源各自 `BEGIN IMMEDIATE`,期望 SQLite 帮你协调。SQLite 的"协调"机制就是让你排队等 5 秒然后报错。`busy_timeout` 不是并发控制,它只是延后 crash。
这里有三个独立问题叠加:
1. **没有 single-writer 约束**:代码层面没人负责串行化写请求。
2. **没有读写分离**:读请求和写请求共用同一连接,读也在抢 writer lock。
3. **没有重试 / 退避**:事务失败直接抛,上层是否处理不确定(B4 和 C6 的 silent catch 问题叠加)。
Karpathy 视角补充:agent 规模一大(10+ agent concurrent)就会被这个问题困住——不是功能不够,是基础设施撑不住。而且这种问题**在 dev 环境永远复现不了**,因为 dev 只有 1-2 个 agent,排队等 5 秒根本触达不到。
## 设计方案
### 架构:Writer Actor + Reader Pool
```
┌─────────────────────────────────────────────────┐
│ Main process │
│ │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ WriterActor │◄───│ enqueueWrite(fn) │ │
│ │ (async │ │ from any caller │ │
│ │ queue) │ └────────────────────┘ │
│ │ │ │
│ │ single │◄── scheduler / heartbeat / │
│ │ writer │ worker / automation / … │
│ │ connection │ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────────┐ │
│ │ SQLite (WAL) │ │
│ └──────▲───────────┘ │
│ │ │
│ ┌──────┴───────────┐ │
│ │ ReaderPool │ │
│ │ (readonly: true │ │
│ │ connections) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 实现
**1. WriterActor(新文件 `src/main/database/writer-actor.ts`)**
```ts
type WriteJob<T> = () => T;
class WriterActor {
private queue: Array<{ job: WriteJob<unknown>; resolve: (v: unknown) => void; reject: (e: unknown) => void }> = [];
private running = false;
private db: DatabaseSync; // 独占的 writer 连接
async enqueue<T>(job: WriteJob<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ job: job as WriteJob<unknown>, resolve: resolve as (v: unknown) => void, reject });
if (!this.running) this.drain();
});
}
private drain() {
this.running = true;
while (this.queue.length > 0) {
const { job, resolve, reject } = this.queue.shift()!;
try {
// 单连接独占,不需要 BEGIN IMMEDIATE 抢锁
resolve(this.db.transaction(job)());
} catch (e) {
reject(e);
}
}
this.running = false;
}
}
```
**关键语义**:
- 写永远串行(actor 内部 FIFO queue)
- 写不再等 busy_timeout——因为没人和它抢
- 任何 writer 都必须走 `writerActor.enqueue(() => db.transaction(...))`
**2. ReaderPool**
```ts
// src/main/database/reader-pool.ts
export function openReader(dbPath: string): DatabaseSync {
return new DatabaseSync(dbPath, { readOnly: true });
}
```
Readers 走独立 connection,`readOnly: true`。N 个 reader 可以并发读(WAL 保证)。
**3. 改造所有 mutation call sites**
| 文件 | 当前写法 | 改造后 |
|------|---------|--------|
| `src/main/orchestration/scheduler.ts` | `db.transaction(...)` | `writerActor.enqueue(() => ...)` |
| `src/main/orchestration/heartbeat.ts` | `db.run("update ...")` | `writerActor.enqueue(() => db.run(...))` |
| `src/main/worker/worker-runner.ts` | 回调直接写 | `writerActor.enqueue(...)` |
| `src/main/automation-service.ts` | rule fire 写 db | `writerActor.enqueue(...)` |
| `src/main/database-base.ts:442-452` | `transaction(fn)` | 保留但改为 reader 专用,writer 入口走 actor |
**4. ESLint rule 强制**
自定义规则:`no-direct-db-write`——禁止在非 `writer-actor.ts` 文件中调用 `db.prepare(...).run(...)`(除非 `readonly: true` 明确标注)。
### Backpressure
队列长度超过阈值(例如 100)时:
- 记 telemetry `writer_queue_depth`
- 超过 1000 直接拒绝新入队,抛 `WriterBusyError`
- 让上游(scheduler / worker)自己决定是否降频
## Migration 计划
1. **Phase 1(1 周)**:新增 writer-actor.ts 和 reader-pool.ts,不改任何 call site。
2. **Phase 2(3 天)**:找出所有 `db.prepare` + `.run` 的 mutation 调用(grep),按文件分批 migrate 到 `writerActor.enqueue`。每批一个 PR,各跑一次 typecheck。
3. **Phase 3(2 天)**:把 read-heavy 的 service(dashboard / list view)切到 reader pool。
4. **Phase 4(1 天)**:打开 ESLint rule,禁止新代码绕过 actor。
5. **Phase 5(持续监测)**:telemetry 观察 writer queue depth p99,1 周后把 busy_timeout 下调到 500ms(writer actor 内部串行后根本不该触达)。
不需要 schema migration——这是纯代码结构重构。
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| Deadlock | actor 内部 job 等待另一个 actor job | 禁止 job 内再调 `enqueue`(ESLint rule + runtime 检查) |
| 吞吐下降 | 串行化本来并行的写 | SQLite 写本来就是串行的,只是从"互等 5 秒"变成"排队",p99 延迟会降不会升 |
| Queue 堆积 | 某个 slow query 堵住 actor | backpressure + telemetry 告警 |
| Reader 读到 stale | WAL 下 reader 看到的是自己 snapshot 的数据 | 读写分离的固有语义,UI 层通过 IPC event 推送更新 |
回滚路径:任何阶段发现问题,把 `writerActor.enqueue(fn)` 改回 `fn()` 直接跑——actor 只是 queue,不改事务语义。
## 验证标准
1. **压力测试**:启动 20 个 agent 同时跑 run,观察 `writer_queue_depth` p99 < 50
2. **SQLITE_BUSY 消失**:生产 telemetry 跑一周,`SQLITE_BUSY` 计数应归零(因为不再有多 writer 抢锁)
3. **行为 eval**(前置 B5):相同 workload 下,改造前后 run 完成率 / tool call 序列一致
4. **Deadlock 单测**:actor 内部 `enqueue` 嵌套直接抛错,不允许死锁发生
5. **Reader 隔离**:kill writer actor 期间,reader 仍能正常服务 read-only IPC
## 不在范围
- **不改 SQLite 版本或 WAL 参数**:现有 WAL + checkpoint 策略保留。
- **不引入 Postgres / 分布式数据库**:SQLite 单机单 writer 是 desktop app 的合理选择。
- **不做 per-company 分片**:单 actor 足够处理单 desktop 的量,分片是未来的事。
- **不重写 transaction 语义**:`db.transaction(fn)()` 包装保留,只是入口走 actor。
- **不改 IPC**:renderer 还是通过 IPC 触发 mutation,只是 handler 内部走 actor。
## 参考文件清单
- `src/main/database-base.ts:433-440`(连接配置 busy_timeout=5000)
- `src/main/database-base.ts:442-452`(transaction 无重试)
- `src/main/orchestration/scheduler.ts`(wake writer)
- `src/main/orchestration/scheduler-heartbeat.ts`(heartbeat writer)
- `src/main/orchestration/task-runner.ts`(dispatch writer)
- `src/main/orchestration/heartbeat.ts`(markHeartbeatAt writer)
- `src/main/worker/worker-runner.ts`(run status writer)
- `src/main/automation-service.ts`(rule fire writer)
## 附:为什么现在修
**现在**的触发器是 company 规模——10+ agent 并发运行时 SQLite 写队列 5 秒 busy_timeout 很容易击穿。此时 transaction rollback 对 caller 表现为"我写成功了",但数据库里没变化——这种 **silent data loss** 是生产事故的标准剧本。
团队一旦有第一个「我明明 save 了啊」的用户工单,排查成本是正式修一次的 5 倍:要查 log、查 SQLITE_BUSY、复现并发条件。不如现在一次性把并发模型收敛掉。
B4 同时是 B2 的防线——B2 的 phase/lease 重构把 tasks 写路径集中了,但其他表(runs / agents / approvals)的写路径如果还散装,多 writer 竞态就会在那里继续发作。
## 附:反模式对比
**Before**(当前):
```ts
// src/main/orchestration/heartbeat.ts(示意)
db.prepare("UPDATE runs SET last_heartbeat_at = ? WHERE id = ?").run(now, runId);
// 这条语句在 worker 并发写 runs 表时会抢 SQLite writer lock,busy_timeout=5000 内没抢到就抛
```
**After**:
```ts
await writerActor.enqueue(() => {
db.prepare("UPDATE runs SET last_heartbeat_at = ? WHERE id = ?").run(now, runId);
});
// 串行入队——永远不抢锁;latency = queue wait + 单次 SQL 时间
```
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B2 phase/lease 已上线,tasks 写路径已收敛 | Owner |
| G2 | Writer actor 跑 stress test:1000 concurrent enqueue,0 失败 | Linus |
| G3 | ESLint rule 运行后,grep `db.prepare.*\.run` 只出现在 writer-actor.ts 和 reader-pool.ts | Linus |
| G4 | 生产 telemetry `writer_queue_depth` p99 < 50 连续 7 天 | Karpathy |
| G5 | `SQLITE_BUSY` 出现次数降到 0 连续 14 天 | Owner |
## 附:backpressure 和降级策略
队列深度观察区间:
| 深度范围 | 行为 | telemetry |
|---------|------|----------|
| 0-10 | 正常 | 采样 1% 记录 |
| 11-50 | 正常但记录 | 100% 记录 queue_depth |
| 51-100 | 触发 warn | telemetry.alert_warn |
| 101-500 | 触发 degraded 模式——scheduler 降频 | telemetry.alert_error |
| >500 | 拒绝新入队(throw WriterBusyError) | telemetry.alert_critical |
降级模式下 scheduler 的 wake 频率从 1s 降到 5s,给 actor 喘息空间。恢复由 queue_depth < 30 自动触发。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:Single-writer actor in-process queue。优点:实现最简;缺点:所有写串行,p99 有队列 latency。
**方案 B(readonly connection pool + write connection pool 各自有锁)**:复杂度高;SQLite 多 writer 连接仍然只有一个真 writer,pool 意义不大。
**方案 C(每个 service 自己带 queue)**:控制分散;依然要协调多 queue 之间的依赖,复杂度翻倍。
**方案 D(换 Postgres)**:超出 scope;desktop 单机不需要外部 DB。
方案 A 成本最低收益最高。
@@ -0,0 +1,267 @@
# Proposal C1: 用 codemod 一次性删除 AppDatabase 80+ legacy pass-through
> 来源:问题.md C1(行 114-127)、附录 F 样本 5(行 495-507)、附录 C(行 390-391)
> 状态:proposed(可与 B2 共享 migration 窗口)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:1 周(含 codemod 脚本 + 逐个 PR 验证)
> Blocking dependencies:无强依赖,但建议 B2 完成后再做(B2 会修改 tasks 模块 API)
## 问题陈述
数据库访问层有两层 pass-through:
**第一层**:`src/main/database-base.ts` 的 `AppDatabaseBase` 持有 26 个领域模块(`threads` / `company` / `agents` / `tasks` / `runs` / ...),在 226-336 行为这些模块的 80+ 方法手写薄委托。
**第二层**:`src/main/database.ts` 的 `AppDatabase`(`AppDatabaseBase` 的子类),在 13-192 行又手写一层更薄的委托,例如:
```ts
// src/main/database.ts:13-23
listComments(taskId: string) { return this.activityMod.listComments(taskId); }
sendAgentMessage(input: {...}) { return this.messages.sendAgentMessage(input); }
// 共计 192 行,几乎全是这种 one-liner
```
作者在 `database-base.ts:49-51` 自己承认:
> "Domain modules. New code should prefer `db.tasks.saveTask(...)` over the legacy pass-through methods on AppDatabase. Pass-through remains for back-compat until all call sites migrate."
没有迁移计划,没有 deadline,没有 ESLint rule 禁止新代码走 pass-through。
## 根因分析
Linus 视角(好品味原则第六条:代码是否可读):**两层委托是认知税**。
读代码路径:`caller → AppDatabase.saveTask → AppDatabaseBase.saveTask → tasks.saveTask → SQL`。每一层都没有任何增值逻辑,纯粹的字符串转发。这是典型的"架构师幻觉"——觉得加一层抽象就是解耦,实际是把同一个事情说三遍。
**真正的解耦**是:call site 和 SQL 之间存在一层做 validation / caching / observability 的东西。当前架构里那一层是空的。
Karpathy 视角补充:AI coding agent 读这个 codebase 时,每次想保存 task 都看到三个等价的 API(`db.saveTask` / `db.saveTask` 继承 / `db.tasks.saveTask`),完全不知道该用哪个。这种 ambiguity 会让 agent 每次选得不一样,导致代码风格 drift——"我之前写过 db.saveTask,这次也写这个" vs "我之前写过 db.tasks.saveTask,这次也写这个"——最后仓库里三种风格并存,review 成本陡增。
根本问题:**"back-compat until all call sites migrate" 没有触发条件**。谁来触发?触发日期?指标?——都没有。所以它就是永远存在。
## 设计方案
### 目标最终形态
```ts
// src/main/database.ts(重写后 ~30 行)
export class AppDatabase extends AppDatabaseBase {
// 保留只有 AppDatabase 才有的逻辑(如果有)
// 没有任何 pass-through
}
// src/main/database-base.ts(删除 226-336 的 pass-through 段落)
export class AppDatabaseBase {
public readonly threads: ThreadsModule;
public readonly company: CompanyModule;
public readonly agents: AgentsModule;
// ... 26 个模块
// 没有 pass-through 方法
}
```
### 所有 call site 强制走命名空间
```ts
// Before
db.saveTask(task)
db.listComments(taskId)
db.sendAgentMessage(input)
// After
db.tasks.saveTask(task)
db.activity.listComments(taskId)
db.messages.sendAgentMessage(input)
```
### Codemod 脚本
新建 `scripts/codemod/remove-appdatabase-passthrough.mjs`:
1. 构建方法名 → 模块名的映射表(从 `database-base.ts:226-336` 和 `database.ts:13-192` 逆向抽取)。例如:
```
saveTask → tasks.saveTask
listTasks → tasks.listTasks
sendAgentMessage → messages.sendAgentMessage
listComments → activity.listComments
… (~80 条)
```
2. 用 ts-morph 或 jscodeshift 扫 `src/` 和 `tests/`,匹配 `db.<method>(...)`,替换为 `db.<module>.<method>(...)`。
3. 对每次替换做 typecheck 验证:`pnpm typecheck` 必须通过。
4. 产出 migration report(每个文件改了几处)。
### ESLint rule 锁死
新规则:`no-appdatabase-passthrough`——禁止在类型为 `AppDatabase` 的对象上调用领域方法(只允许访问 `db.tasks` / `db.agents` 等 getter)。
## Migration 计划
1. **Step 1(1 天)**:写 codemod 脚本 + dry-run 出 report,评估影响面(预计 500-800 处 call site)。
2. **Step 2(2 天)**:按模块分批跑 codemod,每个模块一个 PR:
- `db.threads.*` 一个 PR
- `db.tasks.*` 一个 PR
- `db.messages.*` 一个 PR
- ...
每个 PR 跑 typecheck + 相关 unit/integration test。
3. **Step 3(1 天)**:所有 call site 迁移完后,删 `database-base.ts:226-336` 的 pass-through 段落 + 删 `database.ts:13-192`。
4. **Step 4(半天)**:合并 `AppDatabase` 到 `AppDatabaseBase`,改名为 `AppDatabase`。删除无用的继承层。
5. **Step 5(半天)**:打开 ESLint rule,新 PR 无法再走 pass-through。
不需要 schema migration——这是纯代码结构重构,二进制行为零变化。
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| Codemod 误改 | 动态 property access `db[name]` | codemod 检测到 computed access 时跳过并 warn |
| Typecheck 通过但 runtime 错 | 模块 getter 返回 undefined | `AppDatabaseBase` constructor 里逐个 assert 模块已 init |
| 测试 mock 失效 | 测试 stub 了 `db.saveTask` | 测试改为 stub `db.tasks.saveTask`——codemod 同时处理 |
| 第三方 plugin 依赖 | builtin-skills 或外部代码调 `db.saveTask` | 当前没有第三方 plugin 路径,如发现再加 deprecation proxy |
**回滚路径**:任何 step PR 可单独 revert。final cleanup(step 3-4)前代码层面保持 dual API,任何时候都可暂停。
## 验证标准
1. **Typecheck 通过**:每个 codemod PR + final cleanup PR
2. **所有测试绿**:typecheck / unit / integration / e2e
3. **静态扫描**:最终 `grep -rn "db\.saveTask\|db\.listTasks\|…" src/` 应无命中(除了 `db.tasks.saveTask` 这种合法 namespaced 调用)
4. **ESLint CI 门禁**:新 PR 引入 pass-through 风格被 CI 拒绝
5. **Line count**:`database.ts` 从 192 行 → ≤30 行;`database-base.ts` 从 472 行 → ≤200 行
## 不在范围
- **不重构领域模块内部 API**:只是把 pass-through 删掉,模块内部实现不动。
- **不改 IPC 接口**:renderer 看到的还是相同 shape。
- **不引入 ORM**:SQLite 直接 prepare + run 的风格保留。
- **不做 query builder**:领域模块内部写 SQL 的方式不变。
- **不拆分领域模块到独立文件**:已经在 `src/main/database/` 子目录了,不动。
## 参考文件清单
- `src/main/database.ts:13-192`(第二层 pass-through,待删除)
- `src/main/database-base.ts:40-472`(域模块装配 + 第一层 pass-through)
- `src/main/database-base.ts:49-51`(作者自白注释)
- `src/main/database-base.ts:226-336`(80+ pass-through 方法段)
- `src/main/database/*`(26 个域模块,API 不变)
- `src/renderer/**/*.ts`(主要 call site 消费方)
- `src/main/ipc-*.ts`(IPC handler call site)
## 附:为什么现在修
**broken-window 效应**:只要注释写着"pass-through remains for back-compat"且没有 deadline,新代码就会继续加新的 pass-through。当前 80+ 方法是一天的 codemod,拖到 150+ 就是一周,拖到 300+ 就没人敢动了。
Linus 原话精神:当你发现自己写注释"我知道这很糟但是…"的时候,你就已经在积累技术债。技术债不会自己消失,只会复利。
C1 和 B2 可以共享 migration 窗口——B2 要改 tasks 的状态机 API,codemod 正好顺带把 `db.saveTask → db.tasks.saveTask` 处理掉,两件事一起做比先后做省一半 PR review 工夫。
C1 改完后代码的可读性/可教性明显提升,这个直接影响 AI coding agent 的产出质量——agent 不再在"三个等价 API 选哪个"上浪费 token 和时间。
## 附:codemod 脚本草稿
```js
// scripts/codemod/remove-appdatabase-passthrough.mjs
import { Project, SyntaxKind } from "ts-morph";
const PASSTHROUGH_MAP = {
// 从 database.ts:13-192 和 database-base.ts:226-336 抽取
saveTask: "tasks",
listTasks: "tasks",
deleteTask: "tasks",
saveAgent: "agents",
listAgents: "agents",
sendAgentMessage: "messages",
listComments: "activity",
// ... ~80 条
};
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const sourceFiles = project.getSourceFiles(["src/**/*.ts", "tests/**/*.ts"]);
for (const sf of sourceFiles) {
const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression();
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
const prop = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
const obj = prop.getExpression().getText();
const method = prop.getName();
// 只改 db.xxx(...) 形式
if (obj === "db" && method in PASSTHROUGH_MAP) {
const module = PASSTHROUGH_MAP[method];
prop.setExpression(`${obj}.${module}`);
}
}
}
await project.save();
```
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | Codemod dry-run report 显示预期影响面(500-800 call sites) | Linus |
| G2 | 每个模块的迁移 PR 单独跑 typecheck + unit test 绿 | Owner |
| G3 | 所有模块迁完后 `grep -rn 'db\.saveTask\|db\.listTasks' src/` 无命中 | Linus |
| G4 | `database.ts` 行数 < 30 | Linus |
| G5 | ESLint rule `no-appdatabase-passthrough` 启用,CI 捕获新违规 | Owner |
## 附:call site 分布(预估)
基于 grep:
- `src/main/ipc-*.ts`:~200 处
- `src/main/orchestration/`:~100 处
- `src/main/worker/`:~50 处
- `src/main/agent-runtime/`:~80 处
- `src/renderer/`:~0 处(renderer 通过 IPC,不直接访问 db)
- `tests/`:~100 处
总计 ~530 处。codemod 一次性全改,分批 PR 按文件提交。
## 附:与 B2 的协同
B2 的 phase/lease 重构需要改 `src/main/database/tasks-status.ts` 的 API。codemod 可同时处理两件事:
1. 把 `db.saveTask → db.tasks.saveTask`(C1)
2. 把 `db.tasks.saveTask → db.tasks.advanceTaskPhase`(B2 的新 API)
一个 PR 两件事。节约 review 一次成本。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:一次性 codemod 迁移。优点:彻底;缺点:一次大 PR。
**方案 B(渐进式,新代码用新 API,旧代码原样)**:broken-window 加剧,永远迁不完(过去两年证明了)。
**方案 C(只删 database.ts 的第二层,保留 database-base.ts 的第一层)**:治标不治本,仍有 pass-through 层。
方案 A 是**真的解决问题**的方案。
## 附:PR 拆分策略
按"同一领域的 call site 一批 PR"拆分:
| PR | 迁移范围 | 预计大小 | 评估 |
|----|---------|---------|-----|
| PR-1 | `db.tasks.*` 迁移 | ~150 处 | 跟 B2 合并最省力 |
| PR-2 | `db.agents.*` 迁移 | ~80 处 | 独立 |
| PR-3 | `db.messages.*` 迁移 | ~70 处 | 独立 |
| PR-4 | `db.approvals.*` 迁移 | ~60 处 | 独立 |
| PR-5 | `db.runs.*` 迁移 | ~50 处 | 独立 |
| PR-6 | 其他 21 个模块分 5 批 | ~170 处 | 独立 |
| PR-7 | Final cleanup(删 database.ts pass-through) | - | 所有前置完成后 |
每个 PR 单独跑 typecheck + 相关单测,review 一次 < 1 小时。
## 附:自动 import 优化
Codemod 跑完后,同 file 内如果 `db.tasks.*` 多处调用,可以抽成本地 alias:
```ts
const { tasks } = db;
tasks.saveTask(t);
tasks.listTasks();
```
但这是 optional 优化,由 reviewer 决定是否采纳。
@@ -0,0 +1,260 @@
# 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` 占位符:
```ts
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 构造):
```ts
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 接口
```ts
// 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)
```ts
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`):
```ts
runtimeMessageInjector = new RuntimeMessageInjector({
db,
postToWorker: (msg) => workerService?.getWorker()?.postMessage(msg),
getWakeAgent: () => orchestrationRef.isAssigned() ? orchestrationRef.get().wakeAgentIfPossible : null,
...
});
```
After:
```ts
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 });
}
```
### 服务注册
```ts
// 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 实现
```ts
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 |
@@ -0,0 +1,250 @@
# Proposal C4: initial-user-turn 退回语义 cue + hire 升级为显式 tool
> 来源:问题.md C4(行 158-172)、附录 F 样本 4(行 479-491)
> 状态:proposed(待 B5 eval harness 就绪后实施)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:2 周(其中 1 周做 hire_agent tool,1 周改 prompt + eval 验证)
> Blocking dependencies:**B5(LLM 行为 eval harness)必须先就绪**,否则改 prompt 无法验证行为回归
>
> 圆桌投票记录:Linus 不改,Karpathy 不改,Jobs 改(2-1 决定今晚不动代码,写 proposal 等 eval)
## 问题陈述
`src/main/orchestration/initial-user-turn.ts:62-83` 的 CEO 初始 user turn 把 tool 选择、实现细节、API 路径、业务数字、语气**全写死**:
```ts
return [
"You are the CEO of this company. Your team is empty (zero active direct reports).",
"This wake has ONE job: call the `bash` tool to execute `node -e '...'` fetch POSTs that file 3–5 hire_agent requests against /api/companies/{companyId}/agents, then one approve_ceo_strategy POST against /api/companies/{companyId}/approvals.",
"The exact body shape and connectorId rules are in your Team Building Phase operating instructions above. Use a connectorId and model from the Available Connectors section (only ready connectors).",
"Start the first POST NOW. Do not write a plan document. Do not describe what you will do — execute the tool call.",
].join("\n\n");
```
五个耦合:
1. **Tool 名**:`bash`
2. **实现细节**:`node -e '...'` + `fetch` POST
3. **API 路径**:`/api/companies/{companyId}/agents`、`/api/companies/{companyId}/approvals`
4. **业务数字**:3-5 个 hire + 1 个 approve_ceo_strategy
5. **语气**:NOW / Do not
这段 prompt 没有任何机制让它和 `src/main/prompt-builder/ceo-modes/team-building.en.md` 保持一致——两处都在告诉 CEO "现在该做什么",容易 drift。
## 根因分析
Karpathy 视角(prompt 工程原则):**把实现细节泄漏到 LLM 层是最脆的设计**。
当前 prompt 假设:
- `bash` tool 永远存在(pi-agent-core 未来可能改名或增加 sandbox 模式)
- `node -e` 执行环境永远可用(container 化后可能没 node)
- `/api/companies/...` 路径永远稳定(REST → GraphQL 迁移时全部失效)
- CEO "理解" fetch POST 的 body shape(依赖 prompt 里其他部分说清楚)
每个假设破一次,CEO 就静默做错事。更糟的是——**CEO 一旦学会这个 pattern,其他 agent 会照抄**:所有业务逻辑都 `bash + node -e + fetch`,项目滑向 "everything is bash" 的反模式。
Linus 视角(第一性原理):hire 这件事**本来就应该是一个 tool**。当前让 CEO 通过 HTTP POST 自己的 API server 来 hire——这意味着:
1. main 进程要跑 HTTP server(`api-server.ts`)
2. CEO 要拼 body / 处理 auth / 处理 error
3. 出错只能通过 HTTP status code 感知
直接把 `hire_agent` 做成 pi tool,CEO 调用 tool → main 进程直接 DB 写 → 返回 ack。省掉整个 HTTP 层。
Jobs 视角(为什么他投改):这段 prompt 就是 Jobs 厌恶的"让工程问题泄漏给用户"——CEO 是用户视角的 agent,不该知道 HTTP endpoint。
**为什么今晚不改**(2-1 决定):Linus + Karpathy 认为无 eval 下改 prompt = 盲飞。CEO 的实际行为不能凭"prompt 读起来更干净"来验证,必须有 golden trace 对比。今晚没有 eval 框架(B5 blocked by user constraint "无 tests/fixture")就硬改,可能让 CEO 完全不 hire 而团队当场发现不了。
## 设计方案
分两步:
### Step 1:把 `hire_agent` 升级为显式 pi tool
新建 `src/main/agent-runtime/tools/hire-agent.ts`:
```ts
export const hireAgentTool: AgentTool = {
name: "hire_agent",
description: "Create a new direct report agent for this company. Use this when the CEO needs to fill a role.",
input: {
type: "object",
required: ["name", "role", "description", "connectorId", "model"],
properties: {
name: { type: "string", description: "Short display name for the agent, e.g. 'CTO'" },
role: { type: "string", description: "Business role, e.g. 'Chief Technology Officer'" },
description: { type: "string", description: "1-2 sentences describing responsibilities" },
connectorId: { type: "string", description: "Must be a ready connectorId from the Available Connectors list" },
model: { type: "string", description: "Must be allowed by the connector's enabledModels" }
}
},
handler: async (input, ctx) => {
// 校验 connectorId / model
// 直接调 db.agents.saveAgent(...)
// 返回 agentId
}
};
```
同时引入 `file_approval` tool,替代 `/api/companies/{companyId}/approvals` POST。
### Step 2:把 initial-user-turn 降级为语义 cue
After:
```ts
return [
"You are the CEO. Your team is currently empty.",
"Review your Team Building Phase operating instructions in the system prompt, then take action.",
].join("\n\n");
```
**没有** `bash`、没有 `node -e`、没有 API 路径、没有数字、没有"NOW"。系统 prompt 里的 `ceo-modes/team-building.en.md` 自己说做什么、做多少。
### SOP 层更新
`src/main/prompt-builder/ceo-modes/team-building.en.md` 需要明确:
- 描述任务(组建初始团队)
- 描述可用 tool(`hire_agent`、`file_approval`)
- 描述合理数量(3-5 个角色)
- 不描述具体实现(bash / fetch / HTTP)
这样 CEO 行为 = system prompt 决定,initial-user-turn 只是**触发信号**。
## Migration 计划
1. **Phase 0(前置)**:B5 eval harness 就绪——能 record 当前 CEO 在 team-building trigger 下的 tool call 序列(作为 baseline)
2. **Phase 1(1 周)**:实现 `hire_agent` / `file_approval` tool,注册到 pi-agent-factory。
- CEO 在某些场景开始用新 tool,但保留旧 HTTP API(dual-path)
3. **Phase 2(3 天)**:跑 eval 对比——CEO 在新 tool 可用时的行为 vs baseline
- 必须验证:新 tool 可用时 CEO 优先用 tool,且产出的 hire 记录 schema 与 HTTP 路径一致
4. **Phase 3(3 天)**:修改 `initial-user-turn.ts` 和 `ceo-modes/team-building.en.md`,删除实现细节
5. **Phase 4(1 周)**:eval 验证行为没有回退,下掉 HTTP `/api/companies/{id}/agents` POST 路径(保留 GET / DELETE 用于 UI)
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| CEO 不 hire(改 prompt 后) | 语义 cue 太弱 | eval 直接发现,revert prompt |
| CEO hire 错角色 | 新 tool 接收参数导致发散 | tool schema validation 层拒绝 |
| 旧 call site 依赖 HTTP | UI 或脚本直接调 `/api/companies/{id}/agents` | 保留 HTTP 路径直到 UI 迁移完 |
| eval fixture 未覆盖边缘 | 某些 trigger(例如"空公司 + 单 connector")没 baseline | phase 2 要求覆盖 10+ 初始状态组合 |
**回滚路径**:每个 phase 可独立 revert。tool 可先加不强推;prompt 改动可直接回到硬编码版本。
## 验证标准
**前置:B5 必须就绪**。具体验证:
1. **行为 eval**:在 10+ 种初始 company 状态下,CEO 产出的 hire 数量 / 角色 / 参数,新方案应与旧方案一致(允许 ±1 个 hire 的方差)
2. **Tool 覆盖**:新 `hire_agent` tool 被 CEO 调用的比例 ≥95%(剩余 <5% 走兼容 HTTP 路径)
3. **Prompt 长度**:initial user turn 从 ~400 token 降到 ~80 token
4. **Regression test**:旧 `curl -X POST /api/companies/{id}/agents` 仍能工作(兼容期)
5. **Cache friendliness**:initial user turn 变短后,system prompt 的 cache hit 率提升
## 不在范围
- **不改 system prompt 整体结构**:262 行的系统 prompt 走 C7 proposal 独立重构
- **不改 CEO 模式 state machine**:5 个 ceo-modes 文件结构保留,走 M7 proposal
- **不做 prompt A/B harness**:那是 C7 的事
- **不动 renderer**:hire 在 UI 层的表现不变(创建新 agent 卡片)
- **不改 approval 审批流**:`file_approval` tool 只是路径替换,approval 逻辑本身不动
## 参考文件清单
- `src/main/orchestration/initial-user-turn.ts:62-83`(硬编码段落)
- `src/main/orchestration/initial-user-turn.ts:78-82`(CEO lookup fail-open catch,关联 C6)
- `src/main/prompt-builder/ceo-modes/team-building.en.md`(对应的 operating instructions)
- `src/main/api-server.ts` + `src/main/api-endpoints.ts`(当前承载 hire POST 的 HTTP 层)
- `src/main/worker/pi-agent-factory.ts:131`(tool 注册点)
- `src/main/database/agents.ts`(CEO hire 最终写入的 DB 层)
## 附:为什么这件事不能省略也不能现在 rush
**不能省略**:每次 pi-agent-core 升级(B1 问题指出 62 patch 漂移),`bash` tool 的 schema / 沙盒行为可能悄悄变。prompt 硬编码 `bash + node -e + fetch` 时,升级 pi 就是一场赌博。
**不能现在 rush**(Linus + Karpathy 今晚投反对):prompt 行为是 agent 公司最敏感的地方,无 eval 改 = 赌 CEO 还按原样 hire。圆桌共识:**等 B5,然后一次性做完**。
Karpathy 原则:"没有 eval 的 prompt 改动是对随机函数做微调"——我们今晚同意把它写进 proposal 而不动代码。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B5 eval harness 就绪 + baseline recorded | Karpathy |
| G2 | `hire_agent` tool 实现 + schema 固化 | Owner |
| G3 | Tool 可用期间 eval 通过:CEO 在 10+ 种初始状态下用 tool 而非 HTTP POST | Karpathy |
| G4 | Prompt 精简后 eval:hire 数量 / 角色 / 参数与 baseline 一致 | Karpathy |
| G5 | 旧 HTTP 路径灰度下线 2 周无回退 | Owner |
## 附:新 hire_agent tool 的 schema 草案
```json
{
"name": "hire_agent",
"description": "Create a new direct report agent for this company. Use when a role needs filling.",
"input_schema": {
"type": "object",
"required": ["name", "role", "description", "connectorId", "model"],
"properties": {
"name": {
"type": "string",
"description": "Short display name, e.g. 'CTO', 'Head of Marketing'",
"maxLength": 50
},
"role": {
"type": "string",
"description": "Business role title",
"maxLength": 100
},
"description": {
"type": "string",
"description": "1-2 sentences describing responsibilities and how this person contributes",
"maxLength": 500
},
"connectorId": {
"type": "string",
"description": "Connector ID from the Available Connectors section; must be ready"
},
"model": {
"type": "string",
"description": "Model name allowed by connector.enabledModels"
},
"department": {
"type": "string",
"enum": ["engineering", "product", "design", "marketing", "sales", "operations", "legal", "finance"]
}
}
}
}
```
Tool handler(main 进程内):
1. 校验 connectorId / model 组合合法性
2. 生成 agentId
3. 调 `db.agents.saveAgent(...)` 写入
4. 发 telemetry `agent.hired`
5. 返回 `{ agentId, name, role }`
## 附:反模式对比
**Before**(`src/main/orchestration/initial-user-turn.ts:62-83`):五层耦合混在 prompt 字符串里——agent 必须"解析"提示词才知道要做什么。
**After**:agent 读到"your team is empty → 看 system prompt 的 team-building 段 → 用 hire_agent tool"这条清晰链路。每一步都是 agent 正常 reasoning 能处理的粒度。
## 附:与 M7 的协同
M7(CEO mode state machine)和 C4 共享 `resolveCeoMode` 函数——同一个函数决定:
- initial-user-turn 发什么 cue(C4)
- prompt-builder 加载哪个 ceo-modes markdown(M7)
两件事一次做完,避免 mode 判断在两处漂移。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:退回语义 cue + 显式 tool。
**方案 B(继续硬编码但写成 typed template)**:把字符串换成 template literal + 参数注入。治标不治本——实现细节仍泄漏,只是语法糖。
**方案 C(删掉 initial-user-turn,全靠 system prompt)**:失去"trigger 特定 cue"的能力——当用户 explicit 点击 "start team building" 时没有强信号区分于普通 wake。
方案 A 平衡了清晰度和表达力。
@@ -0,0 +1,280 @@
# Proposal C7: prompt-builder 按 role 条件拼装 + token 预算守门
> 来源:问题.md C7(行 202-215)、附录 C(行 395)、附录 H(行 574-577)
> 状态:proposed(待 B5 + C4 完成后实施)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:2 周
> Blocking dependencies:B5(eval harness)、C4(initial-user-turn 精简)
## 问题陈述
`src/main/prompt-builder/system-prompt.ts` 共 262 行,无条件拼装:
- 15 个 SOP(`prompt-builder/sops/*.md`)
- 11 个 API 文档(`prompt-builder/api-docs/*.md`)
- 5 个 CEO 模式片段(`prompt-builder/ceo-modes/*.md`)
- 7 个部门指南(`prompt-builder/department-guidelines/*.md`)
- Operating instructions (CEO / non-CEO 分支)
- Capability registry
- Skill injection
问题细节:
1. **无条件拼接**:一个 customer_support IC 也会收到全 15 个 SOP(包括 engineering / legal / finance 类)
2. **版本无治理**:每个 SOP/api-doc 的 git blame 是唯一变更记录,没有 `version` frontmatter
3. **无 token 预算守门**:prompt 本身可能吃掉 20-30k input tokens;compactor 还没开始就先把 budget 吃大半
4. **A/B 实验无法做**:改一个 SOP 的某句话无法 attribute 到具体行为变化
5. **Cache invalidation 代价高**:Anthropic prompt caching 对稳定前缀敏感——262 行拼装每改一次就 cache miss 一次
## 根因分析
Karpathy 视角:**prompt 越长越脆**。
长 prompt 的三个问题:
1. **Relevance dilution**:LLM 注意力机制对 prompt 中后段的 attention 权重递减(lost-in-the-middle 现象)。全塞 33 个 markdown 文件反而让关键信号被淹没。
2. **Cross-contamination**:legal SOP 的措辞会影响 engineering agent 的输出风格(比如加 disclaimers);这不是我们想要的。
3. **Evaluation intractability**:33 个片段两两组合有 1000+ 种可能影响;改一个不知道动了哪。
Linus 视角补充:**当前架构假设"一个 prompt 适配所有 agent"**。这和 AppDatabase pass-through 是同构的问题——用一个巨大的 god object 替代多个专用对象。正确做法是按 role 产出不同 prompt。
另一个层面的问题:**没有 budget**。prompt-builder 的输出长度是"算出来多少就多少",caller 无法预判。20k / 30k / 50k token 的 prompt 走 Anthropic 都能跑,但 cost 和 latency 完全不同,而且 compactor(B3 未修时)行为也会随 prompt 长度变化。
## 设计方案
### 核心原则
1. **按 role 条件拼装**:每个 agent 只看到自己需要的
2. **显式 budget**:prompt > threshold 直接 fail fast
3. **片段版本化**:每个 markdown 带 frontmatter `version: X`
4. **Cache-friendly layering**:稳定前缀(不变的 operating instructions)+ 动态后缀(per-turn context)显式分离
### 片段分类表
| 片段类型 | 何时包含 | 共享度 |
|---------|---------|--------|
| Core operating instructions | 总是 | 所有 agent |
| CEO operating instructions | `agent.role === "ceo"` | CEO only |
| CEO mode(team-building/awaiting-*/operating) | CEO + 当前 phase | CEO only |
| Department guidelines | 按 `agent.department` | 同部门 |
| SOPs | 按"与 agent 能力相关" | 有限子集 |
| API docs | 按 connector 可用性 | 动态 |
| Capability registry | 总是 | 所有 agent |
| Skill injection(pi 的 progressive disclosure) | 总是 | 所有 agent |
### 新 API 结构
```ts
// src/main/prompt-builder/index.ts(重写)
export function buildSystemPrompt(ctx: PromptContext): string {
const sections = resolveSections(ctx); // 新增
const parts = sections.map((s) => renderSection(s, ctx));
const prompt = parts.join("\n\n");
enforceBudget(prompt, ctx); // 新增
return prompt;
}
interface SectionRef {
id: string; // "sop.task-delegation" | "department.engineering" | ...
version: string; // 从 markdown frontmatter
required: boolean; // 不满足 required 则 fail
}
function resolveSections(ctx: PromptContext): SectionRef[] {
const sections: SectionRef[] = [
{ id: "core.operating-instructions", version: "1.0", required: true },
{ id: "core.capability-registry", version: "1.0", required: true },
];
if (ctx.agent.role === "ceo") {
sections.push({ id: "ceo.operating-instructions", version: "1.0", required: true });
sections.push({ id: `ceo.mode.${ctx.ceoMode}`, version: "1.0", required: true });
}
if (ctx.agent.department) {
sections.push({ id: `department.${ctx.agent.department}`, version: "1.0", required: true });
}
for (const sop of resolveRelevantSops(ctx)) {
sections.push({ id: `sop.${sop.slug}`, version: sop.version, required: false });
}
// API docs 仅当对应 connector ready 时
for (const connector of ctx.allConnectors ?? []) {
if (connector.status === "ready") {
sections.push({ id: `api-docs.${connector.id}`, version: "1.0", required: false });
}
}
return sections;
}
```
### SOP 相关性规则
替代"全塞"的朴素规则:
1. **SOP 有 `tags` frontmatter**:`tags: [task, delegation, messaging]`
2. **Agent role / department 决定关注 tag 集合**:
- CEO → 全 tag
- Engineering dept → `[task, code, review]`
- Customer support → `[task, messaging, escalation]`
3. **Loader 根据 tag 交集筛选**
Markdown 示例:
```md
---
id: sop.task-delegation
version: 1.2
tags: [task, delegation]
---
# Task delegation SOP
...
```
### Token 预算守门
```ts
// src/main/prompt-builder/budget.ts
const BUDGET_THRESHOLDS = {
warn: 8_000, // 超过 warn 打 telemetry
error: 16_000, // 超过 error 直接抛
};
function enforceBudget(prompt: string, ctx: PromptContext): void {
const tokens = estimateTokensPrecise(prompt, ctx.locale); // 用 tiktoken 精确计算(C3 的 scope)
recordMetric("prompt.tokens", tokens, { role: ctx.agent.role, companyId: ctx.companyId });
if (tokens > BUDGET_THRESHOLDS.error) {
throw new PromptBudgetError(`System prompt ${tokens} tokens exceeds ${BUDGET_THRESHOLDS.error}`);
}
if (tokens > BUDGET_THRESHOLDS.warn) {
logger.warn("prompt.budget.warn", { tokens, threshold: BUDGET_THRESHOLDS.warn });
}
}
```
**Let it crash 原则**:超预算直接 throw,不做"自动裁剪"——因为自动裁剪会悄悄改行为。
### Cache-friendly layering
当前 `buildTaskPrompt`(`prompt-builder/index.ts:48-`)已经把 system prompt(cacheable)和 per-turn context(non-cacheable)分开,这是对的。C7 保留这个分层,进一步:
- system prompt 内部按 stability 排序:`[core → role → department → SOPs → skills]`(稳定度递减)
- Anthropic cache_control breakpoint 标在 `core + role + department` 之后
### A/B harness(可选阶段)
SOP 片段支持 variant:
```md
---
id: sop.task-delegation
version: 1.2
variants:
- id: default
weight: 50
- id: concise
weight: 50
path: ./task-delegation.concise.md
---
```
Loader 按 companyId hash 稳定分配到一个 variant,eval harness(B5)可以对比两个 variant 的行为差异。
## Migration 计划
1. **Phase 0(前置)**:B5 eval harness 就绪 + C4 initial-user-turn 精简完成
2. **Phase 1(3 天)**:给所有 markdown 片段加 frontmatter(`id` / `version` / `tags`),loader 支持读取
3. **Phase 2(1 周)**:实现 `resolveSections` + `renderSection`,行为上保持与当前一致(所有 section 都包含),新旧切换走 feature flag
4. **Phase 3(3 天)**:按 role 开始差异化加载——CEO 仍看全量,非 CEO 开始按 tag 过滤
5. **Phase 4(3 天)**:加 budget enforcement,先 warn-only 跑一周观测分布
6. **Phase 5(持续)**:A/B harness + per-company variant 分配
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| 漏包含 SOP 导致 agent 行为退化 | tag 过滤规则太严 | eval 发现 → 回到"全量包含" flag → 修 tag 规则 |
| Budget error 触发生产崩 | 某 company 的 connector 数量极端 | 阶段 4 warn-only 期间监测 p99;超限前调高或加裁剪 |
| Markdown frontmatter 格式错 | 作者漏写 version | Loader 启动时校验所有片段,任何格式错直接 fail |
| Cache invalidation 增多 | 稳定前缀拆得太细 | 监控 cache hit 率,必要时合并片段 |
## 验证标准
**前置:B5 必须就绪**。
1. **行为 eval**:对比 5 种 agent role × 3 种 trigger 下的 tool call 序列,新旧方案一致
2. **Prompt 长度**:非 CEO agent 的 system prompt 长度降低 40%+
3. **Cache hit rate**:观察 Anthropic cache hit rate,降低 <5%(cache 友好)
4. **Budget 分布**:99% 的 prompt 在 warn 阈值内
5. **A/B pilot**:至少一个 SOP 有 variant,eval 可观测两个 variant 的行为差异
## 不在范围
- **不改 SOP 内容**:只是让加载条件化 + 版本化,具体文字不动(那是 Owner 业务的事)
- **不做 AI-driven prompt optimization**:prompt 片段仍是人写的 markdown
- **不改 pi 的 prompt 结构**:本项目只控制自己注入到 pi 的 systemPrompt,pi 内部行为不动
- **不做 per-agent fine-grained prompt**:按 role / department 分组即可,不做 per-agent 定制
- **不动 token 精确化**(C3 的 scope):本 proposal 可先用近似估算,C3 完成后自动精确
## 参考文件清单
- `src/main/prompt-builder/system-prompt.ts:1-262`(主拼装函数)
- `src/main/prompt-builder/sops/*.md`(15 个 SOP)
- `src/main/prompt-builder/api-docs/*.md`(11 个 API 文档)
- `src/main/prompt-builder/ceo-modes/*.md`(5 个 CEO 模式)
- `src/main/prompt-builder/department-guidelines/*.md`(7 个部门指南)
- `src/main/prompt-builder/operating-instructions.ts` + `operating-instructions-ceo.ts`
- `src/main/prompt-builder/capability-registry.ts`
- `src/main/prompt-builder/skill-injection.ts`
- `src/main/prompt-builder/index.ts`(入口 + per-turn 拼装)
## 附:为什么现在做
prompt caching 的经济账:Anthropic cache 5 分钟 TTL,稳定前缀每 miss 一次多付 ~30% cost。262 行拼装每天被改几次就是每天多付 30% × N 次。这是隐性 recurring cost,估算 company 规模一大、agent 数量一多,年度多付上万美元(有具体数值可做 cost model,不在本 proposal 范围)。
更重要的是:**有了条件拼装 + version frontmatter,AI coding agent 改 prompt 时可以 diff 到具体片段**——而不是在 262 行大 blob 里大海捞针。这直接降低 team 改 prompt 的心理门槛,促进迭代。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B5 eval harness + C4 完成 | Karpathy |
| G2 | 所有 markdown 片段加了 frontmatter | Owner |
| G3 | resolveSections 函数覆盖 CEO / 非 CEO / 所有 department | Owner |
| G4 | Budget enforcement warn 阈值下 95% prompt 不触发 | Karpathy |
| G5 | A/B variant 第一个实验跑出 eval 差异数据 | Karpathy |
## 附:markdown frontmatter schema
```ts
export const SopFrontmatterSchema = z.object({
id: z.string().regex(/^sop\.[a-z-]+$/),
version: z.string().regex(/^\d+\.\d+$/),
tags: z.array(z.string()).min(1),
locale: z.enum(["en", "zh"]).default("en"),
variants: z.array(z.object({
id: z.string(),
weight: z.number().min(0).max(100),
path: z.string().optional(),
})).optional(),
});
```
Build 时校验所有 markdown 符合 schema;不符合直接 fail。
## 附:SOP tag 分类体系(初版草案)
| Tag | 含义 | 关联 role |
|-----|------|---------|
| task | 任务管理相关 | 所有 |
| delegation | 分派相关 | CEO, 管理层 |
| code | 代码相关 | engineering |
| review | 审查相关 | 所有 reviewer |
| messaging | 消息沟通 | 所有 |
| escalation | 升级相关 | 所有 |
| finance | 财务相关 | finance, CEO |
| legal | 法务相关 | legal, CEO |
| marketing | 营销相关 | marketing |
| customer | 客户交互 | support, sales |
规则:SOP 至少有 1 个 tag,agent 根据 role 计算 relevant tag 集合。
@@ -0,0 +1,264 @@
# Proposal M10: agent-runtime 的 hook 聚合到 AgentHooks + 契约版本化
> 来源:问题.md M10(行 278-282)、问题.md C5(行 174-186)
> 状态:proposed(与 C5 契约测试互补)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:1.5 周
> Blocking dependencies:无,但与 C5(hook 契约测试)协同
## 问题陈述
`src/main/agent-runtime/` 当前散装 10 个文件:
| 文件 | 职责 |
|------|------|
| agent-event-projection.ts | run event 投影 |
| artifact-projector.ts | artifact 投影 |
| context-compactor.ts | context 压缩(B3 问题点) |
| cost-writer.ts | cost 记录写入 |
| hook-handlers.ts | hook 回调处理 |
| hook-rpc.ts | hook RPC 层 |
| inbox-projection.ts | inbox 投影 |
| runtime-bridge.ts | 主进程 ↔ pi 桥接 |
| runtime-message-injector.ts | 运行时消息注入 |
| sqlite-session-store.ts | pi session 持久化 |
其中 compactor / cost-writer / inbox-projection / artifact-projector 都是**注入到 pi agent 的 hook**,但分散在不同文件里,没有一个统一的 `AgentHooks` 聚合类。
额外痛点(对照 C5):hook 的签名、调用时序、block-type 约定**深度耦合 pi 版本**——B1 指出的 62 patch 漂移场景下,哪些 hook 还安全完全靠作者记忆。
## 根因分析
Linus 视角:**散装的 hook 没有统一的契约面**。
当前问题:
1. **注册点分散**:`pi-agent-factory.ts` 里 `transformContext: compactor`、`preTurn: inboxProjection`、`postTurn: costWriter` ——每个 hook 各自 import,没有统一枚举
2. **调用顺序隐式**:哪个 hook 先跑、哪个后跑依赖 pi 内部约定,我们没有声明
3. **失败处理不一致**:一个 hook 抛错,其他 hook 是否还跑?—— 当前行为依赖 pi 语义
4. **版本签名无契约**:pi 升级如果改了 hook 参数 shape,本地代码 compile-time 可能通过但 runtime 失败
Karpathy 视角(对应 C5):**agent loop 的控制权在上游**,本地只能通过 hook 介入。hook 是**本项目和 pi 的唯一契约面**——这个契约面没治理就是随机炸。
## 设计方案
### AgentHooks 聚合类
新建 `src/main/agent-runtime/agent-hooks.ts`:
```ts
import type { PiAgentHooks } from "@mariozechner/pi-agent-core";
export interface AgentHooksConfig {
compactor: ContextCompactor;
costWriter: CostWriter;
inboxProjection: InboxProjection;
artifactProjector: ArtifactProjector;
eventProjection: AgentEventProjection;
logger: Logger;
}
export class AgentHooks {
constructor(private config: AgentHooksConfig) {}
toPiHooks(): PiAgentHooks {
return {
transformContext: async (messages, ctx) => {
try {
return await this.config.compactor.maybeCompact(messages, ctx);
} catch (e) {
return this.handleHookError("transformContext", e, { passthrough: messages });
}
},
preTurn: async (ctx) => {
await this.config.inboxProjection.inject(ctx);
},
postTurn: async (ctx) => {
await Promise.all([
this.config.costWriter.write(ctx),
this.config.artifactProjector.project(ctx),
this.config.eventProjection.record(ctx),
]);
},
onError: async (err, ctx) => {
this.config.logger.error("agent.hook.error", { err, ctx });
},
};
}
private handleHookError<T>(hookName: string, err: unknown, opts: { passthrough: T }): T {
this.config.logger.error("agent.hook.error", { hookName, err });
// Fail-safe policy: hook 内部错误不 crash agent loop,但记录 telemetry
return opts.passthrough;
}
}
```
### 契约版本标识
```ts
export const AGENT_HOOKS_PI_COMPAT = "0.67.x";
// 启动时校验
import { version as piAgentVersion } from "@mariozechner/pi-agent-core/package.json";
if (!satisfies(piAgentVersion, AGENT_HOOKS_PI_COMPAT)) {
throw new Error(`Agent hooks not compatible with pi-agent-core ${piAgentVersion}`);
}
```
升级 pi 前必须更新 `AGENT_HOOKS_PI_COMPAT`,强制开发者确认 hook 签名兼容性。
### Fixture 驱动的契约测试(配合 C5)
```
src/main/agent-runtime/__fixtures__/
├── transform-context/
│ ├── input-30-messages.json
│ └── expected-output.json
├── pre-turn/
│ └── inbox-injection.json
└── post-turn/
└── cost-write.json
```
契约测试:每个 fixture 的 hook 输入/输出 shape 必须稳定。升级 pi 必须跑这套测试——如果 shape 变了,测试 fail,必须显式处理 migration。
### hook-handlers.ts / hook-rpc.ts 归类
这两个文件是 pi runtime 和 main 进程之间的 RPC(hook 执行位置 ≠ 进程位置)。本 proposal 不合并它们到 `agent-hooks.ts`,保留独立文件,但 agent-hooks 成为**入口**——外部代码只 import `AgentHooks`,内部如何走 RPC 是实现细节。
## Migration 计划
1. **Phase 1(2 天)**:实现 `AgentHooks` 类,内部调用保留原有 4 个 hook 模块
2. **Phase 2(2 天)**:pi-agent-factory.ts 从散装 import 切到 `new AgentHooks(...).toPiHooks()`
3. **Phase 3(3 天)**:加版本兼容标识 + 启动校验
4. **Phase 4(1 周)**:补 fixture 和契约测试
5. **Phase 5(持续)**:下次 pi 升级走完整 flow——跑 fixture 测试 + review `AGENT_HOOKS_PI_COMPAT`
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| Hook 执行顺序变化 | 聚合后若 pi 顺序改变 | 聚合类维持和原来一致的执行顺序,加注释 |
| Fixture 维护成本 | 每次 pi 改 hook shape 都要更新 fixture | 接受——这正是契约测试的目的 |
| 兼容范围过严 | `^0.67.0` vs `0.67.68` | 升级时 explicit 更新 compat 字符串 |
| Hook 吞 error | `handleHookError` 掩盖真实 bug | 强制 telemetry 记录;同时让 critical 场景(例如 cost 写入失败)走 throw 而不是 passthrough |
## 验证标准
1. **Typecheck 通过**:hook 签名与 pi 类型严格一致
2. **启动校验**:pi 版本不 satisfies 时应用启动 fail fast
3. **契约测试覆盖**:4 个主要 hook 都有 fixture + 验证
4. **Telemetry 可观测**:hook 错误被记录到 telemetry,不是静默吞
5. **Refactor 零行为变化**:相同 agent run,新旧方案产出的 event stream / cost record 一致
## 不在范围
- **不重写 compactor 内部逻辑**(B3 / C3 是独立 proposal)
- **不改 pi 上游**:只能在 hook 层适配
- **不做 hook 的 hot-reload**:启动一次加载即可
- **不实现自动 pi 升级**:升级仍是人工决策 + 跑 fixture
- **不合并 hook-handlers 和 hook-rpc**:它们是不同抽象层(业务 vs 协议)
## 参考文件清单
- `src/main/agent-runtime/context-compactor.ts:52-73`(B3 问题代码)
- `src/main/agent-runtime/cost-writer.ts`
- `src/main/agent-runtime/inbox-projection.ts`
- `src/main/agent-runtime/artifact-projector.ts`
- `src/main/agent-runtime/agent-event-projection.ts`
- `src/main/agent-runtime/hook-handlers.ts`
- `src/main/agent-runtime/hook-rpc.ts`
- `src/main/agent-runtime/runtime-bridge.ts`
- `src/main/agent-runtime/runtime-message-injector.ts`
- `src/main/agent-runtime/sqlite-session-store.ts`
- `src/main/worker/pi-agent-factory.ts:131`(hook 注册点)
- `pi-mono-reference/packages/agent/`(上游 hook 接口定义)
## 附:为什么现在修
pi 升级(B1 已完成的 0.67.68 pin)之前团队每次升级 pi 都是赌博——hook 是否还兼容没人能验证。M10 + C5 的契约测试是把"我猜还能跑"变成"测试证明能跑"的唯一办法。
M10 单独的收益:代码可读性——新工程师打开 `pi-agent-factory.ts` 不用猜哪几个 hook 在做什么,看 `AgentHooks` 的 `toPiHooks()` 一目了然。
注意与 B3(compactor tool_use 切腰斩)的关系:B3 是 compactor 内部的正确性问题,M10 是 hook 接入层的组织问题。两个正交,B3 先做(BLOCKER),M10 后做。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | `AgentHooks` 类与现有 pi 版本 0.67.68 契约对齐 | Linus |
| G2 | 4 个主要 hook 每个有 fixture 和契约测试 | Karpathy |
| G3 | 启动时校验 pi 版本在兼容范围,不在则 fail fast | Owner |
| G4 | Telemetry 可观察 hook 错误率,7 天无新 silent drop | Owner |
| G5 | 下次 pi 升级走完整 flow 验证 | Owner |
## 附:与 C5 的关系
C5(hook 契约测试)是 M10 的验证手段——M10 做好聚合后,C5 提供契约测试 harness。两件事 logically 可以并行做,但实现上 M10 先于 C5 更自然(先有聚合类再给它加测试)。
## 附:反模式对比
**Before**(`src/main/worker/pi-agent-factory.ts:131` 附近):
```ts
const agent = createPiAgent({
transformContext: contextCompactor.maybeCompact.bind(contextCompactor),
preTurn: inboxProjection.inject.bind(inboxProjection),
postTurn: async (ctx) => {
await costWriter.write(ctx);
await artifactProjector.project(ctx);
},
// ...每次加 hook 都要改 factory
});
```
**After**:
```ts
const hooks = new AgentHooks({ compactor, costWriter, inboxProjection, artifactProjector, ... });
const agent = createPiAgent(hooks.toPiHooks());
// 加新 hook 只改 AgentHooks.toPiHooks(),factory 不动
```
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:AgentHooks 聚合类 + 版本兼容标识。
**方案 B(完全重写 agent loop,不用 pi)**:成本远大于收益——pi 已经处理了 token 流、provider 切换、retry 等。
**方案 C(把 hook 留在独立文件,但加统一的 factory helper)**:没解决"hook 入口分散"问题,签名仍各自 drift。
方案 A 成本低、收益清晰。
## 附:fixture 目录结构示例
```
src/main/agent-runtime/__fixtures__/
├── README.md
├── transform-context/
│ ├── 30-messages-with-tool-pairs.json
│ ├── 30-messages-no-tool-calls.json
│ ├── cjk-heavy-content.json
│ └── empty-input.json
├── pre-turn/
│ ├── inbox-with-unread-messages.json
│ └── inbox-empty.json
├── post-turn/
│ ├── successful-tool-call.json
│ ├── assistant-text-only.json
│ └── error-turn.json
└── version-matrix/
└── compat-0.67.68.json
```
每个 fixture 包含 input + expected output + 注释说明。升级 pi 时跑 `pnpm test:hook-contracts` 一键验证。
## 附:hook error handling 策略矩阵
| Hook | 错误策略 | 理由 |
|------|---------|------|
| transformContext (compactor) | passthrough(返回原 messages) | compactor 挂不应该阻塞 agent 前进 |
| preTurn (inbox projection) | throw(让 agent loop 挂) | inbox 注入失败意味着 agent 读不到新消息,前进下去会 base 错误 |
| postTurn (cost writer) | throw | cost 写入失败是数据完整性问题,必须 crash |
| postTurn (artifact projector) | passthrough + telemetry | artifact 缺失不阻塞后续 |
| postTurn (event projection) | throw | event 丢失无法事后补,必须 crash |
| onError | 记 log | terminal hook,不能 rethrow |
@@ -0,0 +1,251 @@
# Proposal M11: 统一 CredentialManager(OAuth + API key + vault 三合一)
> 来源:问题.md M11(行 284-288)、附录 I 第 1 条(行 587)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:2 周
> Blocking dependencies:无
## 问题陈述
`src/main/credentials/` 模块当前有 4 个文件:
- `credential-resolver.ts`
- `credential-store.ts`
- `oauth-login-service.ts`
- `types.ts`
同时还有独立的:
- `src/main/vault.ts`(secret 加密存储)
- `src/main/connector-definitions-data-apikey.ts`(API key 类 connector 定义)
- `src/main/connector-definitions-data-oauth.ts`(OAuth 类 connector 定义)
- `src/main/connector-definitions-data-cloud.ts`(Cloud 类 connector 定义)
**四个不同的"我是谁"来源**散落,secret 生命周期(如何过期 / 如何轮换 / 如何跨 connector 共享)不明确。
具体问题:
1. **Resolve 路径不统一**:有的走 `credentialResolver.resolve(id)`,有的直接 `vault.decrypt(key)`,有的走 `credentialStore.lookup(connectorId)` —— call site 多样,每种 resolve 路径的失败模式也不同
2. **Secret 生命周期**:OAuth token 有 `expires_at`,API key 通常没有,cloud-hosted 凭证可能有 API-specific expiry —— 当前没有统一的 "refresh / rotate" 流程
3. **Vault 被直接 expose**:某些 service 直接调 `vault.decrypt(...)`,绕开 credentialResolver 的缓存/审计
4. **Connector 定义分三份**:相同的 connector "XX" 可能同时在 apikey / oauth 两份定义里存在,用户 UI 看到的 connector 状态由谁算真源不确定
5. **权限边界不明**:哪些 service 能读什么凭证?当前没有 ACL,任何 service 都能 import credentialResolver 和 vault
## 根因分析
Linus 视角(安全边界是数据结构的第一公民):**"凭证"是一个抽象概念,当前项目没有把它建模成一等公民**,而是按类型(OAuth vs API key)分别处理,导致每种类型各成体系。
真正的抽象应该是:
```
Credential (abstract)
├── metadata: { id, scope, expiresAt, ... }
├── provider: OAuthProvider | ApiKeyProvider | CloudProvider
└── resolve(): Promise<{ secret: string, metadata: Metadata }>
```
provider 负责具体的获取和刷新逻辑,上层只关心"给我 X 连接器的凭证"——类型差异被封装。
Karpathy 视角:agent 调用 connector 时只想"用 X 连接器",不关心这个连接器是 OAuth / API key / Cloud。当前分三份 connector 定义意味着 UI 和 agent 都需要做类型判断——属于把实现细节泄漏到 consumer。
## 设计方案
### 统一的 CredentialManager
新建 `src/main/credentials/credential-manager.ts`:
```ts
export interface Credential {
id: string;
connectorId: string;
kind: "api_key" | "oauth" | "cloud_hosted";
expiresAt: number | null;
}
export interface ResolvedCredential {
secret: string;
metadata: Credential;
}
export class CredentialManager {
constructor(
private vault: Vault,
private store: CredentialStore,
private oauth: OAuthLoginService,
) {}
async resolve(connectorId: string, opts?: { skipRefresh?: boolean }): Promise<ResolvedCredential> {
const credential = this.store.lookup(connectorId);
if (!credential) throw new CredentialNotFound(connectorId);
switch (credential.kind) {
case "api_key":
return this.resolveApiKey(credential);
case "oauth":
return this.resolveOAuth(credential, opts);
case "cloud_hosted":
return this.resolveCloud(credential);
}
}
async rotate(connectorId: string): Promise<void> {
// 统一的轮换入口
}
async revoke(connectorId: string): Promise<void> {
// 统一的吊销入口
}
private async resolveOAuth(cred: Credential, opts?: { skipRefresh?: boolean }): Promise<ResolvedCredential> {
if (cred.expiresAt && cred.expiresAt - Date.now() < REFRESH_BEFORE_MS && !opts?.skipRefresh) {
await this.oauth.refresh(cred.id);
}
const secret = await this.vault.decrypt(cred.id);
return { secret, metadata: cred };
}
private async resolveApiKey(cred: Credential): Promise<ResolvedCredential> {
const secret = await this.vault.decrypt(cred.id);
return { secret, metadata: cred };
}
private async resolveCloud(cred: Credential): Promise<ResolvedCredential> {
// Cloud 场景走 provider API 拿 ephemeral token
...
}
}
```
### Vault 只做存储,不暴露 API
Vault 变成 `CredentialManager` 的内部实现——其他模块不再直接 import `vault.ts`。
```ts
// vault.ts 只 export 内部使用的类
export class Vault {
// internal methods
}
```
ESLint rule:禁止 `credentials/` 目录之外的代码直接 import `vault.ts`。
### Connector 定义合并
三份 connector 定义文件合并为统一结构:
```ts
// src/main/connectors/definitions/index.ts
export interface ConnectorDefinition {
id: string;
label: string;
authKind: "api_key" | "oauth" | "cloud_hosted";
enabledModels?: string[];
// 根据 authKind 分支
apiKey?: ApiKeyConfig;
oauth?: OAuthConfig;
cloud?: CloudConfig;
}
```
所有 connector 在一个地方注册,UI / agent 不再需要判断类型——CredentialManager 和 ConnectorRegistry 各自消费 `authKind`。
### 审计和权限
- 所有 `resolve` 调用记 telemetry:`credential.resolved`(connectorId, caller, timestamp)
- 敏感操作(`rotate` / `revoke`)需要显式 caller 标识
- Log 中**永不**打印 secret(redactor 检查)
## Migration 计划
1. **Phase 1(3 天)**:建 `CredentialManager` 类,内部调用现有 `credentialResolver` / `credentialStore` / `oauthLoginService`(组合层)
2. **Phase 2(1 周)**:所有 call site 改用 `credentialManager.resolve(connectorId)`——grep `credentialResolver` / `vault.decrypt` 的使用处
3. **Phase 3(3 天)**:合并 connector 定义文件到统一结构
4. **Phase 4(2 天)**:加 ESLint rule 禁止直接 import `vault.ts`
5. **Phase 5(持续)**:下次 OAuth refresh token 过期 bug 修复时,在 CredentialManager 层统一处理,不再散修
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| Secret 解密路径变 | 合并后某些 edge case 路径丢失 | Phase 1-2 保留旧路径,逐步迁移 |
| OAuth refresh 时序 | `resolveOAuth` 的 `REFRESH_BEFORE_MS` 判断不当 | 可配置;保守值开始 |
| Connector 定义合并冲突 | 同一个 connector 在两份定义里有 | 合并时 explicit choose,由 Owner 确认 |
| 审计 log 过大 | 每次 resolve 都记一条 | 加 sample rate 或只记重要 connector |
## 验证标准
1. **静态 scan**:`grep -rn "vault\.decrypt\|vault\.encrypt" src/main/` 除 `credentials/` 内之外应无命中
2. **Typecheck**:全项目通过
3. **Connector UI 一致**:UI 显示的 connector 状态与 `credentialManager.listAvailable()` 一致
4. **OAuth refresh 正确**:模拟 token 过期场景,resolve 自动 refresh
5. **Telemetry**:每次 resolve 可见于 telemetry,无 secret 泄漏到 log
## 不在范围
- **不重写 OAuth flow**:OAuth 登录 UI 和流程保留,只是 resolve 路径改
- **不改 vault 加密算法**:现有 AES-GCM / OS keychain 集成不动
- **不做 multi-user 隔离**:单用户 desktop app,不需要
- **不做凭证同步**(跨设备):独立 feature
- **不引入密钥管理服务**(HashiCorp Vault / AWS Secrets Manager)
## 参考文件清单
- `src/main/credentials/credential-resolver.ts`
- `src/main/credentials/credential-store.ts`
- `src/main/credentials/oauth-login-service.ts`
- `src/main/credentials/types.ts`
- `src/main/vault.ts`(改为 credentials/ 内部)
- `src/main/connector-definitions-data-apikey.ts`
- `src/main/connector-definitions-data-oauth.ts`
- `src/main/connector-definitions-data-cloud.ts`
- 所有 call site(grep `credentialResolver` / `vault.decrypt` 找到)
## 附:为什么现在修
每多加一个 connector 类型(例如未来支持 JWT / mTLS / device cert),现在的分层结构都要再拆一个文件、再加一条分支逻辑。统一到 CredentialManager 后,新 authKind 只是加一个 case 分支。
**安全角度的刚性需求**:审计和权限一旦变成合规要求(例如要求记录"哪个 agent 用了哪个凭证"),当前散装结构根本没法快速加上。现在改还是 refactor,等到合规逼迫再改就是紧急项目。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | `CredentialManager` 可同时 serve OAuth / API key / Cloud 三类 | Owner |
| G2 | 所有 call site 迁移完,grep `vault\.decrypt` 仅限 `credentials/` 内 | Linus |
| G3 | 三份 connector definition 合并为单一结构 | Owner |
| G4 | 审计 log 记录 7 天无 secret 泄漏 | Security |
| G5 | OAuth refresh 单元测试通过:token 过期 < REFRESH_BEFORE 时自动刷新 | Owner |
## 附:secret 生命周期治理
| 凭证类型 | 创建 | 刷新 | 轮换 | 吊销 | 审计事件 |
|---------|------|------|------|------|---------|
| OAuth | OAuth flow | 自动 (refresh token) | 手动触发 | OAuth revoke endpoint | created/refreshed/revoked |
| API key | 用户粘贴 + vault 加密 | 不适用 | 用户替换 | 用户删除 | created/rotated/revoked |
| Cloud hosted | 登录 | Provider API 换短期 token | Provider 端管理 | Provider 端删除 | created/exchanged/revoked |
CredentialManager 对外 API 不区分这三类——内部根据 `kind` 走对应 provider。
## 附:反模式对比
**Before**:
```ts
// 随便某个 service 里
const secret = await vault.decrypt(connectorId); // 任何人能调
const token = await oauth.getCurrentToken(connectorId); // 绕过 vault
```
**After**:
```ts
const { secret, metadata } = await credentialManager.resolve(connectorId);
// 唯一入口;自动 refresh;自动审计;不暴露 vault API
```
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:统一 CredentialManager。
**方案 B(给每种 authKind 独立的 Manager,再加一个 Dispatcher)**:多一层,没增加清晰度。
**方案 C(保留现状,只加 wrapper)**:wrapper 不能阻止旧 call site 继续绕路。
方案 A 是唯一真正统一的方案。
+252
View File
@@ -0,0 +1,252 @@
# Proposal M12: renderer 状态层审查(IPC 契约化后做)
> 来源:问题.md M12(行 290-294)、附录 D(行 403-408)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:3-4 周
> Blocking dependencies:**M2(IPC 契约化)必须先完成**
## 问题陈述
104 个 IPC channel(见 M2)基本都在给 renderer 推状态:
- profile snapshot
- task list
- run events
- browser actions
- approvals
- messages
- …
renderer 如何 debounce、merge、避免 stale read,**本次 review 未深入**——但缺乏契约层的前提下几乎必然有一致性 bug。
典型问题猜测(需 M2 完成后验证):
1. **Race condition**:两个 IPC response 几乎同时到达,后到覆盖先到,但语义上应该合并
2. **Stale read**:renderer 发 request → main 返回 → 中间 main 已经又写了一次 → renderer 拿到 stale snapshot
3. **过度 refetch**:renderer 没有合适的 cache,每次切换页面都重 fetch(浪费 IPC round trip)
4. **Event dedup**:同一个 run event 被多个订阅路径接收,渲染时重复展示
5. **Optimistic update 回滚**:renderer 本地 optimistic 更新,但 main 拒绝后没有正确回滚
## 根因分析
当前未知——本 proposal 承认**review 没覆盖 renderer**。
Linus 视角推测:104 个 channel × N 种 payload shape × 若干订阅者 = 巨大的状态空间。没有类型契约(M2 未完成)时,renderer 层的一致性问题**无法静态证明也无法系统性测试**。M2 是前置条件——契约化后才能逐个 channel audit。
Karpathy 视角补充:renderer 的 UI state 错乱直接让用户看到 "agent 跑着但 UI 显示闲置" 或 "approval 已批但列表还在" 等典型 bug。这种 bug 很难用 unit test 捕捉,需要 e2e 验证 + state invariant checking。
## 设计方案
本 proposal 是**审查方案**而不是修复方案——具体修法要等审查结果。
### 审查范围
`src/renderer/` 全部代码,重点:
1. **状态管理库**:目前是 zustand(从 `package.json` 看到依赖)
2. **IPC 订阅点**:`useEffect` 里调 `window.api.invoke` / 订阅 event 的位置
3. **数据合并逻辑**:event 到达时如何 merge 到 store
4. **缓存策略**:哪些数据缓存、TTL 多少、如何失效
5. **Optimistic update**:哪些 action 先改 UI 再等 ack
### 审查 checklist
| 项 | 检查内容 |
|----|---------|
| 1 | 每个 zustand store 的 state shape 是否 type-safe(借助 M2 的 IpcOutput 类型) |
| 2 | 每个 IPC invoke 的 response 处理,是否考虑 race(后到请求覆盖先到请求) |
| 3 | 每个 event subscription,是否 dedup(同一 event 不同路径接收) |
| 4 | Optimistic update 的回滚逻辑完备性 |
| 5 | 组件 unmount 时 subscription cleanup |
| 6 | 跨 route 切换时 store 是否 reset(避免显示上一个 company 的数据) |
| 7 | 长列表的 virtualization(react-virtual 已在依赖里,实际使用覆盖度) |
| 8 | Loading / error / empty 三种边界态覆盖 |
### 预期产出
审查结束后产出 `docs/proposals/M12-renderer-findings.md`,按严重度列出发现的具体问题,每条给修复 proposal(可能演化为独立 proposal)。
### 审查方法
1. **静态**:
- grep `window.api.invoke` 找所有 call site
- grep `useEffect` + IPC 调用 找订阅点
- 对照 M2 的 IpcChannels 一一核对 shape
2. **动态**:
- 启动 app,开 React DevTools 看 zustand store 状态
- 触发典型场景(agent run、approval flow、task update),观察 state transition
- 故意制造 race(背景 run 发 event + 前景用户交互)
3. **e2e**:
- 用 Playwright(tests/e2e 已配置)写 invariant 检查脚本:
- 任何时刻 UI 显示的 "pending approvals" 数量 = main 进程 DB 查询结果
- 任何时刻 UI 显示的 "active runs" = DB runs where status=running
## Migration 计划(审查后)
审查阶段:
1. **Phase 1(1 周)**:静态扫描 + 形成 findings 列表
2. **Phase 2(1 周)**:动态测试 + Playwright invariant script
3. **Phase 3(1 周)**:按严重度归类,高严重度的 findings 进入后续 sprint 修复
4. **Phase 4(1-2 周)**:根据 findings 可能派生出新的 proposal(例如"renderer-state-race-fixes")
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| 审查范围失控 | 发现问题太多,sprint 无法覆盖 | 按严重度分批,每个 sprint 处理一类 |
| 修复引入新 bug | 动 renderer state 逻辑 | 每类修复独立 PR + e2e 验证 |
| M2 没完成就启动 | 契约化未完成导致审查无类型依据 | 不做,等 M2 |
## 验证标准(审查阶段)
1. **覆盖率**:grep 可见所有 `window.api.invoke` 都被审查
2. **Findings 数**:产出具体 finding 列表(预估 20-40 条)
3. **E2E invariant**:至少 5 条关键 invariant 被 playwright 脚本覆盖
4. **修复 backlog**:高优先 finding 进入下个 sprint
## 不在范围
- **不做 renderer 架构重写**:只是审查 + 针对性修复
- **不换状态管理库**:zustand 保留
- **不改 UI 设计**:只审数据流
- **不重构 route 层**:url routing 逻辑独立
- **不做 A/B 测试设施**:UI 实验独立 proposal
## 参考文件清单
- `src/renderer/`(全部,待审查)
- `src/preload/index.ts`(IPC bridge)
- `src/main/ipc-*.ts`(main 侧 handler)
- `tests/e2e/playwright.config.ts`(e2e 测试配置)
- M2 契约表(前置依赖)
## 附:为什么现在放一个"审查 proposal" 而不是修复 proposal
本次代码 review(2026-04-20)**明确跳过了 renderer 深入**——review report 也坦白了这点。列 M12 不是占位,是承认"这里有未知的坑"。
**第一性原理**:不能在没契约(M2)的前提下审 renderer 状态——审的结论无法落成 executable 测试。必须等 M2 完成后,renderer 层的问题才有具体形状。
M12 也是 IPC-related 所有 proposal 的收官——M2 完成 + M12 审查完 + 修复完,整个 main ↔ renderer 的契约面才算治理完整。
Karpathy 视角:Agent 的 UI 呈现错误("agent 跑完了但还在 spinner")直接伤害产品 perception——用户不会分辨是 LLM bug 还是 state bug,一律认为"这 agent 不可靠"。所以 renderer state 治理的优先级实际比列 M 级更高,但依赖链强迫它排在后面。
## 附:审查输出模板
审查结束后 `docs/proposals/M12-renderer-findings.md` 的结构:
```markdown
# M12 Findings (<date>)
## 🔴 Critical findings
### F-01: <具体问题>
- 文件:src/renderer/xxx.tsx:<行号>
- 重现:<最小重现步骤>
- 影响:<用户层面影响>
- 建议修复:<简短描述>
## 🟠 Major findings
...
## 🟡 Minor findings
...
## Covered e2e invariants
1. <invariant 1>
2. <invariant 2>
...
```
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | M2 IPC 契约化完成 | Linus |
| G2 | 审查 checklist 8 项全覆盖 | Owner |
| G3 | Findings 列表产出并分类(≥20 条) | Linus |
| G4 | e2e invariant 脚本 ≥5 条可运行 | Karpathy |
| G5 | 高优先 findings 进入下 sprint 修复 | Owner |
## 附:关键 invariant 候选
e2e playwright 要覆盖的状态不变式:
1. UI "pending approvals" count = DB approvals where status = 'pending'
2. UI "active runs" = DB runs where status = 'running'
3. Task card 显示的 phase = DB tasks.phase(B2 完成后)
4. Agent message 未读数 = DB agent_message_reads 未读行数
5. 某 company 被切换后,UI 不应残留前一 company 数据
6. Approval 批准后,UI 应在 1s 内从 pending 列表消失
7. Task 从 todo → in_progress 时,spinner 应立即显示
8. Browser action 执行中,main 进程可见 subprocess status = running
## 附:为什么现在不做 proposal 只做审查
当前 review 团队(Linus + Karpathy + Jobs)**没有覆盖 renderer**——贸然给出修复 proposal 等于凭想象出方案。本 proposal 诚实承认这是"审查准备"阶段,具体修复待发现。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:先审查后修复,M2 做前置。
**方案 B(一次性 renderer 重写)**:风险极大——renderer 代码量不小,重写周期长,期间 UI 完全不可用。
**方案 C(按路由逐 page 重写)**:逻辑上可行,但 findings 可能跨 page(例如多个 page 用同一个 bug store),按 page 重写解决不了。
方案 A 是发现问题 → 针对性修复的稳妥路径。
## 附:常见 renderer state 反模式(待审查时排查)
审查重点观察以下 pattern:
1. **useEffect 里 invoke 没 AbortController**:组件 unmount 但 promise 仍 resolve,导致 "setState on unmounted component"
2. **event listener 没 cleanup**:`ipcRenderer.on("xxx", h)` 返回的 listener 没在 `useEffect` 清理
3. **派生 state 存 store**:本可以 derive 的数据写入 store,引入一致性问题
4. **Optimistic update 没 rollback**:`store.setState({ ... })` 后 invoke 失败,UI 保持错误态
5. **同一 data 多次 fetch**:切换 tab 每次重 fetch,没有 cache 或 cache 失效策略不当
6. **Route 切换 store 不 reset**:上一个 company 的数据残留到当前 company 视图
7. **Event dedup 缺失**:同一 run_event 被多个路径转发到 renderer,渲染时重复
8. **List 未 virtualize**:大列表渲染全量 DOM,滚动卡顿
审查时对每个 store 和每个大组件逐项核查。
## 附:playwright e2e invariant 脚本骨架
```ts
// tests/e2e/invariants.spec.ts(M12 产出)
test("pending approvals UI = DB", async ({ page, electronMain }) => {
await page.goto("/approvals");
const uiCount = await page.locator("[data-testid=pending-approval]").count();
const dbCount = await electronMain.evaluate(() =>
db.approvals.count({ status: "pending" })
);
expect(uiCount).toBe(dbCount);
});
test("active runs UI = DB", async ({ page, electronMain }) => {
await page.goto("/runs");
const uiRunning = await page.locator("[data-status=running]").count();
const dbRunning = await electronMain.evaluate(() =>
db.runs.count({ status: "running" })
);
expect(uiRunning).toBe(dbRunning);
});
// ... 至少 5 条 invariant
```
## 附:renderer store 梳理(待 M2 完成后形成清单)
M2 完成后,M12 审查第一步:把每个 zustand store 列出来 + 它订阅的 IPC channel 映射,形成:
| Store | 订阅 channel | 更新策略 | 发现的问题 |
|-------|------------|---------|----------|
| agentsStore | `agents/list` + `agents/updated` event | ? | 待审 |
| tasksStore | `tasks/list` + `tasks/updated` event | ? | 待审 |
| ... | ... | ... | ... |
## 附:e2e harness 运行频率
- PR 触发:快速 invariant(< 30s 跑完的子集)
- 夜间 build:全量 invariant + soak test(5 分钟持续交互)
- Release gate:全量 + manual QA pass
M12 审查结束后产出的 invariant 脚本会同时塞进这三层。
+258
View File
@@ -0,0 +1,258 @@
# Proposal M2: 104 IPC handler 契约化(集中 channel 定义 + 类型化 invoke)
> 来源:问题.md M2(行 227-234)、附录 D(行 403-408)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:2 周
> Blocking dependencies:无(独立于其他 proposal)
## 问题陈述
主进程注册的 IPC handler 总数 **104**(`grep -c "ipcMain\.handle\|registerHandle("` 全 main 目录),分散在 22 个文件:
- `ipc-agent-coordination-handlers.ts`
- `ipc-automation-operations-handlers.ts`
- `ipc-browser-handlers.ts`
- `ipc-budget-handlers.ts`
- `ipc-content-operations-handlers.ts`
- `ipc-core-admin-handlers.ts`
- `ipc-core-agent-graph-handlers.ts`
- `ipc-core-common.ts`
- `ipc-core-handlers.ts`
- `ipc-core-operations-handlers.ts`
- `ipc-core-read-handlers.ts`
- `ipc-core-task-graph-handlers.ts`
- `ipc-core-work-graph-handlers.ts`
- `ipc-credentials-handlers.ts`
- `ipc-domain-common.ts`
- `ipc-domain-handlers.ts`
- `ipc-oauth-handlers.ts`
- `ipc-onboarding-handlers.ts`
- `ipc-recovery-handlers.ts`
- `ipc-skill-handlers.ts`
- `ipc-support.ts`
- `ipc-thread-handlers.ts`
所有 channel 名都是**字符串字面量**:`ipcMain.handle("agents/list", ...)`、`ipcRenderer.invoke("agents/list", ...)`。preload 和 main 之间无 schema 约束,renderer 里任何拼写错误都是 runtime 静默失败。
## 根因分析
Linus 视角:**无类型约束的大面积 RPC surface 是可观测性黑洞**。
当前问题:
1. **字符串拼写错误**:renderer 写 `"agents/lsit"`(typo),preload invoke 不报错,main 没有 handler → Promise 永不 resolve 或超时
2. **参数 shape 漂移**:main 改了 handler 接收的参数结构,renderer 还按旧 shape 发——JSON 反序列化成功但字段丢失,产出异常由业务层感知
3. **返回值类型假设**:renderer 期望 `{ ok: true, data: ... }`,main 返回 `data` 裸值或 `null`——typeof 判断错了又是静默 bug
4. **静态 grep 困难**:改 channel 名要 grep 字符串,漏一处就断一个功能
5. **新 channel 命名漂移**:`core-admin` / `core-agent-graph` / `core-common` 这种前缀暴露"core 是啥都不清楚"的命名漂移——审查时得打开文件才知道属于哪个域
Karpathy 视角补充:AI coding agent 写 renderer 代码时,没有 IDE 自动补全 channel 名,每次都 grep 猜一次——写错概率很高。即使写对了 channel,payload shape 也是 tribal knowledge。
## 设计方案
### 核心:单一 contract 源
新建 `src/shared/ipc-contracts.ts`(renderer 和 main 都能 import):
```ts
// 所有 channel 集中定义
export const IpcChannels = {
"agents/list": {
input: z.object({ companyId: z.string() }),
output: z.object({ agents: z.array(AgentRecordSchema) }),
},
"tasks/save": {
input: TaskRecordSchema,
output: z.object({ taskId: z.string() }),
},
"approvals/resubmit": {
input: z.object({ approvalId: z.string() }),
output: z.object({ ok: z.literal(true) }),
},
// ... 104 条
} as const;
export type IpcChannelName = keyof typeof IpcChannels;
export type IpcInput<K extends IpcChannelName> = z.infer<typeof IpcChannels[K]["input"]>;
export type IpcOutput<K extends IpcChannelName> = z.infer<typeof IpcChannels[K]["output"]>;
```
### main 侧:强制契约注册
```ts
// src/main/ipc-core-common.ts(改造)
export function registerIpcHandler<K extends IpcChannelName>(
channel: K,
handler: (input: IpcInput<K>) => Promise<IpcOutput<K>> | IpcOutput<K>
): void {
const contract = IpcChannels[channel];
ipcMain.handle(channel, async (_evt, rawInput) => {
const input = contract.input.parse(rawInput); // validation
const output = await handler(input);
return contract.output.parse(output); // validation
});
}
```
`registerIpcHandler` 不接受 raw string——channel 名必须是 `IpcChannels` 的 key。TypeScript 会在编译期拒绝 typo。
### preload 侧:强制类型化 invoke
```ts
// src/preload/index.ts(改造)
async function invokeIpc<K extends IpcChannelName>(
channel: K,
input: IpcInput<K>
): Promise<IpcOutput<K>> {
return await ipcRenderer.invoke(channel, input);
}
contextBridge.exposeInMainWorld("api", {
invoke: invokeIpc,
});
```
### renderer 侧使用
```ts
// Before
const result: any = await window.api.invoke("agents/list", { companyId });
// After
const result = await window.api.invoke("agents/list", { companyId });
// result 被推导为 { agents: AgentRecord[] }
```
IDE 自动补全 channel 名;input 写错类型直接红线;output 不需要断言。
### 命名规范化
现有 channel 名混乱(`core-admin` / `core-agent-graph` / `agents/list` / `tasks.save` 等),一次性规整:
- Format: `<domain>/<verb>`(小写 / slash 分割)
- Domain 列表固定:`agents / tasks / runs / approvals / messages / documents / skills / automations / browser / credentials / oauth / onboarding / recovery / threads / companies / connectors / budgets`
- 不再有 `core-*` 前缀——哪个域就叫哪个域
迁移时保持旧 channel 名作为 alias 几个版本,telemetry 观察老名字消失再删。
## Migration 计划
1. **Phase 1(3 天)**:建 `src/shared/ipc-contracts.ts`,先把 10-20 个最核心的 handler 迁移
2. **Phase 2(1 周)**:剩余 80+ handler 分批迁移,每批一个 PR(按 handler 文件分)
3. **Phase 3(2 天)**:命名规范化——renderer 代码统一改新 channel 名
4. **Phase 4(2 天)**:main 侧老 channel 名走 alias 层,telemetry 观察
5. **Phase 5(2 周后)**:删掉 alias,彻底移除旧名
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| zod 开销 | 每次 IPC 都跑 parse | 大 payload 场景 benchmark;必要时用更轻的 validator 或 conditional parse |
| renderer 类型推导失败 | 某个 schema 递归 / 复杂 | 拆 schema 或用 type assertion 临时绕过 |
| 契约漂移 | 有人改 schema 没改 call site | typecheck 会直接报 —— 这是目标 |
| 遗漏的 channel | grep 漏了某个 registerHandle | Phase 1-2 分批时每批跑完 grep,剩余 channel 列表归零 |
## 验证标准
1. **Typecheck**:全项目 `pnpm typecheck` 通过
2. **Grep 归零**:`grep -rn 'ipcMain\.handle\s*(\s*"' src/main/` 应归零(全部走 `registerIpcHandler`)
3. **Renderer 零 any**:renderer 层 IPC 调用处无 `as any` / `// @ts-ignore`
4. **Runtime validation**:故意给 handler 发错 payload,应被 zod parse 拒绝(不是进入业务逻辑再 crash)
5. **Telemetry**:迁移期间监控 unknown-channel 事件,迁完应归零
## 不在范围
- **不改 IPC 传输机制**:仍用 Electron 原生 `ipcMain.handle` / `ipcRenderer.invoke`
- **不做跨进程 pub-sub**:channel 还是 request-response;事件推送走独立 `webContents.send` 通道
- **不做 versioning**:本次一次性切换,不做 v1/v2 并存(成本 > 收益)
- **不引入 gRPC / protobuf**:zod schema + TypeScript 足够
- **不做权限控制**:所有 channel 对 renderer 开放,权限层面走独立 proposal
## 参考文件清单
- `src/main/ipc-*.ts`(22 个 handler 文件)
- `src/preload/index.ts`(contextBridge expose)
- `src/renderer/**/*.ts`(消费者,数百处 invoke 调用)
- `src/shared/types.ts`(已有部分 schema,可复用)
## 附:为什么现在修
**AI coding agent 最大的错误来源之一**就是 IPC 调用——字符串 channel + any payload,一旦没查到正确的字符串就瞎猜,猜错就是静默失败,反复几次才能找对。把契约集中化以后,AI 写代码时 IDE 直接告诉它 channel 名和 payload shape,一次对。
另一个触发因素:M12(renderer state audit)要做前提必须先有 M2。当前 renderer 的状态一致性问题一部分就来自 IPC 契约不明——消息传错、payload 错、renderer 把错数据当对数据渲染。先修 M2 再做 M12。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | `src/shared/ipc-contracts.ts` 覆盖 104 个 channel | Linus |
| G2 | 所有 main 侧 handler 通过 `registerIpcHandler` 注册 | Linus |
| G3 | Preload `invokeIpc` 类型化后,renderer 层 `as any` 归零 | Linus |
| G4 | 命名规范化后 alias 保留 2 周观察,无旧名残留 | Owner |
| G5 | Runtime zod parse 错误率监控 7 天 | Karpathy |
## 附:channel 命名规范化 before/after 示例
| Before | After |
|--------|-------|
| "core-admin:list-profiles" | "profiles/list" |
| "core-agent-graph:save" | "agents/save" |
| "core-task-graph:delete" | "tasks/delete" |
| "core-work-graph:list-runs" | "runs/list" |
| "domain:approvals/resubmit" | "approvals/resubmit" |
| "oauth:start" | "oauth/start" |
| "browser:take-action" | "browser/action" |
"core-*" 前缀彻底消失——哪个 domain 就叫哪个 domain。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:集中 contract + zod runtime validation + codemod 迁移。
**方案 B(仅加 TypeScript 类型不做 runtime validation)**:main / renderer 进程间 payload 反序列化后类型已失效——runtime 仍可能错 shape。zod 必须。
**方案 C(gRPC / protobuf)**:Electron IPC 不需要这种重量级框架;zod 足够。
**方案 D(只规范命名不做契约化)**:核心问题是缺契约,改名只是治标。
方案 A 一次性解决类型、命名、runtime validation 三件事。
## 附:zod vs 其他 validator
| 选项 | 优点 | 缺点 |
|------|------|------|
| zod | 类型推导原生 + 广泛使用 | runtime 开销 ~5μs/parse |
| io-ts | fp-ts 生态 | 学习曲线 |
| yup | 成熟 | TS 推导不如 zod |
| typebox | 速度最快 + JSON Schema | API 偏底层 |
选 zod:项目已有 `zod@4.3.6` 依赖,无需新增;推导质量好。
## 附:Event push 通道(非 invoke)
Electron 还有 `webContents.send` → `ipcRenderer.on` 的单向事件推送。本 proposal 同样契约化:
```ts
export const IpcEvents = {
"runs/updated": z.object({ runId: z.string(), status: RunStatusEnum }),
"approvals/updated": z.object({ approvalId: z.string() }),
"tasks/updated": z.object({ taskId: z.string() }),
// ...
};
// main 侧
emitEvent<K extends IpcEventName>(channel: K, payload: IpcEventPayload<K>) {
const validated = IpcEvents[channel].parse(payload);
webContents.send(channel, validated);
}
// renderer 侧
subscribeEvent<K extends IpcEventName>(channel: K, handler: (payload: IpcEventPayload<K>) => void): () => void {
const wrapped = (_evt, raw) => handler(IpcEvents[channel].parse(raw));
ipcRenderer.on(channel, wrapped);
return () => ipcRenderer.off(channel, wrapped);
}
```
Event 和 invoke 走同一套契约风格,减少认知负担。
+281
View File
@@ -0,0 +1,281 @@
# Proposal M5: landing/ 与 desktop app 拆分(pnpm workspace 或独立仓)
> 来源:问题.md M5(行 248-252)、附录 F 第 8 条(行 541-542)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:1 周(workspace 方案)或 2 周(独立仓方案)
> Blocking dependencies:无
## 问题陈述
`landing/` 目录是独立的 marketing site(Next.js + Cloudflare Functions),自带:
- 独立 `package.json`
- 独立 `node_modules`
- 独立 build 流程
- 独立 deploy(Cloudflare Pages)
- 独立的 SQL migration(`landing/migrations/`)
和 desktop app(Electron + React)混在同一个 git repo 里。`scripts/generate-landing-release-links.mjs` 做跨项目协调,暗示两个产品要**手动同步**某些 artifact。
问题表现:
1. **贡献者 onboarding 费劲**:clone 后不知道要在根目录还是 landing/ 跑 pnpm install;CI 要跑哪个
2. **release 耦合**:desktop app 发版和 landing 发版必须同时推到同一个 commit——其中一个有问题就拖另一个
3. **依赖污染**:根 `node_modules` 和 `landing/node_modules` 可能安装同一个包的不同版本
4. **CI 资源浪费**:改一个 landing 文件触发整个 desktop 的 typecheck/test
5. **安全面混淆**:landing 的 Cloudflare token、数据库 URL 和 desktop 的签名证书放在同一个仓库,权限边界模糊
## 根因分析
Linus 视角:**一个 repo 两个产品 = release 耦合**。
Monorepo 本身不是错——Google / Facebook 用 monorepo。但 monorepo 需要**工具层面的约束**:
- 统一 task runner(nx / turborepo / bazel)
- 明确的 workspace 划分
- 共享依赖 hoisting 策略
- CI 按 changed-files 触发
当前项目有 pnpm 但没 workspace 配置,landing 实际上就是"目录塞在这里的另一个项目",没有 monorepo 的任何好处——**只有成本**。
Karpathy 视角补充:AI coding agent 在这种结构下很容易在 desktop 任务里改到 landing 文件,或反之。隔离不清楚的 repo 让 agent 的 blast radius 变大。
## 设计方案
### Option A:pnpm workspace(推荐,低成本)
**目标结构**:
```
AgentCompany/
├── package.json # root (workspace root, 无 runtime 代码)
├── pnpm-workspace.yaml # 新增
├── packages/
│ ├── desktop/ # 当前的根内容移到这里
│ │ ├── package.json
│ │ ├── src/
│ │ ├── tests/
│ │ └── ...
│ └── landing/ # 当前的 landing/ 移到这里
│ ├── package.json
│ ├── app/
│ └── ...
├── pi-mono-reference/ # 保留在 root(现状)
└── docs/
```
`pnpm-workspace.yaml`:
```yaml
packages:
- "packages/*"
```
**Root package.json**(变简):
```json
{
"name": "agentcompany-root",
"private": true,
"scripts": {
"desktop:dev": "pnpm --filter desktop dev",
"desktop:build": "pnpm --filter desktop build",
"landing:dev": "pnpm --filter landing dev",
"landing:build": "pnpm --filter landing build",
"typecheck": "pnpm -r typecheck",
"lint": "pnpm -r lint"
}
}
```
**CI 按 package 分跑**:desktop PR 只跑 desktop typecheck/test,landing PR 只跑 landing。
**好处**:
- 两个项目独立 `package.json`、独立版本
- 根目录干净
- 共享开发工具(eslint / tsconfig base)可提到 root
- pnpm 自动 hoist 共享依赖,磁盘占用下降
**成本**:
- git history 移动(git mv 保留 blame)
- CI 脚本改写(`scripts/` 要调整路径引用)
- 发布流程(electron-builder / cloudflare deploy)适配新路径
### Option B:独立仓(彻底隔离)
把 landing/ 推到独立 repo(例如 `github.com/badlogic/agentcompany-landing`),现 repo 只保留 desktop。
**好处**:
- 完全隔离 CI / secrets / 权限
- landing 团队可独立迭代不看 desktop 代码
- desktop 的 code review 范围缩小
**成本**:
- 跨 repo 协调(release link 生成脚本要走 GitHub API)
- Onboarding 要 clone 两个 repo
- git history 完整迁移工作量
### 推荐 A
- 当前团队规模不需要完全隔离
- landing 改动相对少,一个 repo 能保持 cross-cutting 改动(例如品牌一致性)的原子性
- workspace 成本最低,收益立刻兑现
## Migration 计划(Option A)
1. **Phase 1(1 天)**:新建 `packages/desktop/` 空目录 + `packages/landing/`;`pnpm-workspace.yaml`
2. **Phase 2(1 天)**:`git mv` 把当前根代码移到 `packages/desktop/`,landing/ 移到 `packages/landing/`
- 根 `package.json` 拆成 root + desktop
- 根配置(`tsconfig.base.json` / `eslint.config.mjs`)保留 root
3. **Phase 3(1 天)**:修 `scripts/*` 里的硬编码路径(`scripts/release-*.mjs`、`scripts/build-*.mjs`)
4. **Phase 4(1 天)**:CI 配置分包跑
5. **Phase 5(1 天)**:文档同步——README 更新;`CLAUDE.md` / `AGENTS.md` 说明 workspace 结构
6. **Phase 6(持续)**:观察 2 周,处理零星问题
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| git history 丢失 | git mv 不规范 | 用 `git mv` 保留 rename detection;必要时 `git log --follow` 验证 |
| electron-builder 找不到 artifact | 打包路径变化 | Phase 3 脚本改完后跑一次完整 build 验证 |
| Cloudflare deploy 路径改 | landing build output 位置变 | wrangler.toml 更新;deploy 前本地 dry-run |
| renderer import path 断 | 代码中的硬路径 | codemod 扫 `require("../../landing")` 类的跨 package 引用(理论上没有) |
**回滚路径**:phase 2 是单个 commit(git mv),任何后续阶段出问题直接 revert。
## 验证标准
1. **独立 build 成功**:`pnpm --filter desktop build` 和 `pnpm --filter landing build` 分别跑通
2. **依赖完整**:`pnpm install` 根目录跑完 `pnpm -r typecheck` 全绿
3. **CI 提速**:改 landing 文件的 PR 不触发 desktop test
4. **Release 脚本**:`pnpm run release:local` / `pnpm release:boss` / `pnpm release:cloudflare:public` 都能跑
5. **Git blame 保留**:`git log --follow packages/desktop/src/main/service-graph.ts` 能追到原始 commit
## 不在范围
- **不引入 turborepo / nx**:pnpm workspace 已足够,加 task runner 是过度工程
- **不改代码模块边界**:desktop 内部代码结构本 proposal 不动
- **不做跨 package 共享代码**:landing 目前不需要 desktop 的类型;如果未来需要再加 `packages/shared/`
- **不动 pi-mono-reference/**:那是第三方 reference 副本,保留在 root
- **不改 git branching strategy**:单仓单分支 vs 多分支不是本 proposal 问题
## 参考文件清单
- `landing/package.json`(独立 package 定义)
- `landing/migrations/`(独立 SQL migration)
- `scripts/generate-landing-release-links.mjs`(跨项目脚本)
- `scripts/release-public-cloudflare.mjs`(landing 发布)
- `scripts/release-boss.mjs`(desktop 发布)
- 根 `package.json:1-86`(会被拆分)
- 根 `pnpm-lock.yaml`(workspace 化后会更长但更清晰)
## 附:为什么现在修
**延迟成本**:landing 每加一个 dependency 就让根 `node_modules` 更大;desktop 的 CI 时间持续上升。现在拆 1 天,拖到 landing 翻倍大小就是 3 天+。
对 AI coding agent 来说,workspace 化后"这个改动属于哪个 package"一目了然——agent 不再会在 desktop 任务里 accidentally 修 landing 文件。这是隔离 blast radius 的最低成本方式。
次要触发:M5 和 M4(scripts 目录混乱)相关——scripts 里的 `release-public-cloudflare.mjs` / `release-boss.mjs` / `release-local.mjs` 混在一起部分就是因为 desktop/landing 不分。拆了 workspace,scripts 也可以按 package 分。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | `pnpm-workspace.yaml` 存在,`pnpm install` 在根目录一次跑通 | Owner |
| G2 | `pnpm --filter desktop build` 产出可签名的 `.app` bundle | Owner |
| G3 | `pnpm --filter landing build` 产出可部署的 Cloudflare Pages 资源 | Owner |
| G4 | CI 按 changed files 触发相应 package 测试 | Owner |
| G5 | git log --follow 在两个 package 内都能追到原始 commit | Linus |
## 附:迁移后的目录结构草图
```
AgentCompany/
├── package.json # workspace root
├── pnpm-workspace.yaml
├── pnpm-lock.yaml # hoisted
├── tsconfig.base.json # shared
├── eslint.config.mjs # shared
├── .gitignore
├── CLAUDE.md / AGENTS.md
├── docs/
├── pi-mono-reference/ # 保留根(第三方 reference)
├── scripts/
│ ├── desktop/ # electron-builder / signing / release
│ └── landing/ # cloudflare deploy / release-links
└── packages/
├── desktop/
│ ├── package.json
│ ├── src/
│ │ ├── main/
│ │ ├── preload/
│ │ ├── renderer/
│ │ └── shared/
│ ├── tests/
│ ├── builtin-skills/
│ ├── electron-vite.config.ts
│ └── tsconfig.json # extends ../../tsconfig.base.json
└── landing/
├── package.json
├── app/
├── migrations/
├── next.config.js
└── tsconfig.json
```
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:pnpm workspace,一个仓两个 package。
**方案 B(独立仓)**:对当前团队规模不经济;跨仓协调成本高。
**方案 C(保持现状 + 更多 gitignore 隔离)**:治标不治本;CI 仍跑全量。
方案 A 成本 1 周,收益持续。
## 附:release 脚本改造示意
```json
// packages/desktop/package.json scripts 片段
{
"scripts": {
"build": "electron-vite build && node ../../scripts/desktop/build-desktop.mjs",
"release:local": "node ../../scripts/desktop/release-local.mjs",
"release:boss": "node ../../scripts/desktop/release-boss.mjs"
}
}
// packages/landing/package.json scripts 片段
{
"scripts": {
"build": "next build",
"release": "node ../../scripts/landing/release-cloudflare.mjs"
}
}
// root package.json
{
"scripts": {
"release:all": "pnpm --filter desktop release:boss && pnpm --filter landing release"
}
}
```
## 附:CI 按 changed files 触发(示意)
```yaml
# .github/workflows/desktop-ci.yml
on:
pull_request:
paths:
- "packages/desktop/**"
- "pnpm-lock.yaml"
- "tsconfig.base.json"
# .github/workflows/landing-ci.yml
on:
pull_request:
paths:
- "packages/landing/**"
```
desktop PR 不触发 landing CI,反之亦然。
+270
View File
@@ -0,0 +1,270 @@
# Proposal M7: CEO 模式状态机化(resolveCeoMode 单一真源)
> 来源:问题.md M7(行 260-264)
> 状态:proposed(依赖 B5 + C4)
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:1.5 周
> Blocking dependencies:B5(eval harness)、C4(initial-user-turn 精简)
## 问题陈述
CEO 当前处于哪个模式,由**两处独立逻辑**各自决定:
**位置 1**:`src/main/prompt-builder/ceo-modes/` 下 5 个 markdown 文件
- `awaiting-hire.en.md`
- `awaiting-org.en.md`
- `operating.en.md`
- `proactive-footer.en.md`
- `team-building.en.md`
加载逻辑在 runtime 靠**字符串匹配**——某段代码根据 company/CEO 状态推出一个 mode 名,然后按文件名拼路径读 markdown。
**位置 2**:`src/main/orchestration/initial-user-turn.ts:62-83`
- 独立判断 CEO 是否 "zero active reports"
- 独立判断是否"awaiting hire approval"
- 独立决定发什么 cue
**两处都在决定 "CEO 现在该做什么",互相不知道对方存在**。结果是:`ceo-modes` 文件可能说当前是 `operating` 模式(有团队在运行),而 `initial-user-turn` 同时决定发 "your team is empty" 的 cue——内部打架。
## 根因分析
Linus 视角:**同一件事(CEO 当前状态)被两个代码位置各自计算**——这是第一性的违规。状态应该是单一 source of truth。
当前设计把 "mode" 当成一个**魔法字符串**在 prompt-builder 和 initial-user-turn 之间传递(甚至不是显式传,而是两边各自推),没有一个函数说"this is where CEO mode is decided"。
加载 markdown 的路径也是隐式约定——`ceo-modes/team-building.en.md` 的命名约定没有 schema 保证,漏一个文件、写错一个 mode 名、加新 mode 时忘了建对应文件,都是静默失败。
Karpathy 视角:**模式分支是 agent 行为最关键的决策点**——CEO 在 team-building / operating / awaiting-* 下的应答完全不同。这种分支必须**集中**并有 eval 覆盖每个分支。
## 设计方案
### 单一 resolveCeoMode 函数
新建 `src/main/orchestration/ceo-mode.ts`:
```ts
export type CeoMode =
| "team_building" // 无 active report,需要 hire
| "awaiting_hire" // 已提交 hire approval,等人批
| "awaiting_org" // 组织架构待确认
| "operating" // 正常运行态
;
export interface CeoModeContext {
agent: AgentRecord;
company: CompanyRecord;
activeAgents: AgentRecord[];
pendingApprovals: ApprovalRecord[];
executionMode: ExecutionMode;
}
export function resolveCeoMode(ctx: CeoModeContext): CeoMode {
if (ctx.agent.role !== "ceo") {
throw new Error("resolveCeoMode called on non-CEO agent");
}
const activeReports = ctx.activeAgents.filter(a => a.ceoId === ctx.agent.id && a.status !== "terminated");
const pendingHires = ctx.pendingApprovals.filter(a => a.kind === "hire_agent");
if (activeReports.length === 0 && pendingHires.length === 0) {
return "team_building";
}
if (activeReports.length === 0 && pendingHires.length > 0) {
return "awaiting_hire";
}
if (ctx.company.orgStatus === "pending_review") {
return "awaiting_org";
}
return "operating";
}
```
**所有**判断 CEO 模式的代码都必须调这个函数。
### Prompt-builder 从 mode 映射到片段
```ts
// src/main/prompt-builder/ceo-modes/index.ts(新增)
const modeToSlug: Record<CeoMode, string> = {
team_building: "team-building",
awaiting_hire: "awaiting-hire",
awaiting_org: "awaiting-org",
operating: "operating",
};
export function loadCeoModePrompt(mode: CeoMode, locale: AppLocale): string {
const slug = modeToSlug[mode];
// 加载 `ceo-modes/{slug}.{locale}.md`
}
```
通过 TypeScript 枚举保证 mode 和 markdown 文件的一一对应——加新 mode 必须加枚举 + 对应 markdown,编译期强制。
### initial-user-turn 走 resolveCeoMode
`src/main/orchestration/initial-user-turn.ts` 不再重复判断 CEO 状态,调 `resolveCeoMode(ctx)`:
```ts
const mode = resolveCeoMode(modeCtx);
const cue = getCeoModeCue(mode, trigger);
```
这样 prompt-builder 里的 markdown 和 initial-user-turn 的 cue **通过同一个 mode 驱动**——永远一致。
### proactive-footer 归类
当前的 `proactive-footer.en.md` 不是一个 mode——它是所有 CEO 模式共享的 footer。应该:
- 重命名目录结构:`ceo-modes/{mode}.md` 专门装 mode-specific
- 另起 `ceo-shared/proactive-footer.md` 放共享片段
- 避免把 footer 误当作第 5 个模式
## Migration 计划
1. **Phase 0(前置)**:B5 eval harness + C4 完成
2. **Phase 1(2 天)**:实现 `resolveCeoMode` 函数 + 单测(不动 call site)
3. **Phase 2(3 天)**:prompt-builder 从 mode enum 加载 markdown,删除字符串拼接
4. **Phase 3(3 天)**:initial-user-turn 重构——删除自行判断逻辑,全走 `resolveCeoMode`
5. **Phase 4(3 天)**:eval 对比——相同 CEO 状态下行为一致性
6. **Phase 5(1 天)**:重组目录——proactive-footer 从 ceo-modes 移出
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| 模式分类漏 corner case | awaiting_hire 和 awaiting_org 同时成立 | resolveCeoMode 函数里明确 priority + 单测覆盖 |
| eval 发现行为偏移 | 新 mode decision 逻辑和旧隐式判断不等价 | 保留旧 initial-user-turn 分支 flag,对比 N 轮 run |
| Markdown 内容过期 | enum 加了新 mode 但没写 markdown | 加载时 assert 文件存在,启动 fail fast |
| Locale 缺失 | `awaiting-hire.zh.md` 不存在但 locale=zh | fallback 到 `.en.md` + telemetry 告警 |
## 验证标准
1. **Typecheck 保证完整性**:新增 mode 必须同时更新 enum + modeToSlug map + markdown 文件,否则编译 fail
2. **Eval 行为对比**:每个 mode 至少 3 种代表性 company 状态,新旧行为 99% 一致
3. **Call site 归一**:`grep -rn "team_building\|awaiting_hire\|operating" src/main/` 只在 `ceo-mode.ts` 里出现枚举定义,其他地方通过 import 使用
4. **Markdown coverage**:所有 mode 在所有支持的 locale 下都有文件,启动时校验通过
5. **Initial-user-turn 简化**:`initial-user-turn.ts` 行数降低(假设从 159 行 → < 100 行)
## 不在范围
- **不重写 ceo-modes 的 prompt 内容**:只是让加载机制严谨,具体文字走 Owner 业务决策
- **不加新 mode**:当前 4 个 mode 足够(proactive-footer 不是 mode)
- **不改 non-CEO agent 的模式逻辑**:他们没有这种 mode 概念
- **不做 mode transition 审计 log**:有需要另开 event 表
- **不做 mode-specific tool gating**:tool 可用性和 mode 解耦(不同 mode 可用相同 tool,只是 prompt 引导不同)
## 参考文件清单
- `src/main/prompt-builder/ceo-modes/awaiting-hire.en.md`
- `src/main/prompt-builder/ceo-modes/awaiting-org.en.md`
- `src/main/prompt-builder/ceo-modes/operating.en.md`
- `src/main/prompt-builder/ceo-modes/proactive-footer.en.md`(将重组)
- `src/main/prompt-builder/ceo-modes/team-building.en.md`
- `src/main/orchestration/initial-user-turn.ts:62-83`(重复逻辑)
- `src/main/prompt-builder/system-prompt.ts`(mode 注入点)
- `src/main/database/agents.ts`(CEO 状态 query)
- `src/main/database/approvals.ts`(pending hire approval query)
## 附:为什么现在修
C4 已经把 initial-user-turn 的"实现细节泄漏"修了——如果不顺便做 M7,就会出现 initial-user-turn 变干净了但还在自行判断 mode 的状态。一次做完,两件事共享同一个 resolveCeoMode 函数,改 prompt 的团队只需要 reason about 一个真源。
M7 也是未来扩展 CEO 行为的基础——如果要加 "awaiting_funding" / "pivoting" 之类的新模式,没有 state machine 就是继续堆字符串判断。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | B5 eval harness 可 record CEO 在各 mode 下的 baseline 行为 | Karpathy |
| G2 | `resolveCeoMode` 单测覆盖 4 种 mode × 边缘状态 | Linus |
| G3 | prompt-builder 通过 enum 加载 markdown,启动时验证文件存在 | Owner |
| G4 | initial-user-turn 重构后行为 eval 与 baseline 一致 | Karpathy |
| G5 | proactive-footer 从 ceo-modes 目录移出,路径调整不破坏现有 prompt | Owner |
## 附:state machine 扩展指南
未来加新 mode 的最小改动点:
1. `src/main/orchestration/ceo-mode.ts` 的 `CeoMode` enum 增加成员
2. `resolveCeoMode` 函数加对应 if 分支
3. `ceo-modes/<new-mode>.en.md`(+ 其他 locale)新建
4. `modeToSlug` 映射加一行
5. 对新 mode 写单测(至少 1 个 positive + 1 个 negative case)
TypeScript 会强制 1-4 的完整性——漏一步编译 fail。
## 附:反模式对比
**Before**(`initial-user-turn.ts:62-83` + `ceo-modes/*.md` 两处各自判断):
```ts
// 位置 1: initial-user-turn.ts
if (activeReports.length === 0) {
return teamBuildingCue;
}
// 位置 2: prompt-builder/system-prompt.ts (加载 ceo-modes)
const modeFile = loadCeoModeFile(company, agent); // 内部逻辑不一致
```
**After**(单一真源):
```ts
const mode = resolveCeoMode(ctx);
const cue = getCeoModeCue(mode, trigger);
const modeSection = loadCeoModePrompt(mode, locale);
```
两处都 derive from mode,保证一致。
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:enum + resolve 函数 + markdown 映射。
**方案 B(把 mode 存 DB)**:不必要——mode 完全由当前状态 derive,存 DB 反而要维护一致性。
**方案 C(把 markdown 内容放代码里)**:失去给产品经理 / 业务 owner 迭代 prompt 的低成本路径。
方案 A 保留 markdown 灵活性同时让加载逻辑类型安全。
## 附:mode priority 排序规则
`resolveCeoMode` 判断顺序(先匹配即返回):
1. `team_building`:无 active report + 无 pending hire
2. `awaiting_hire`:无 active report + 有 pending hire
3. `awaiting_org`:有 active report + org 待 review
4. `operating`:默认
顺序基于"外部依赖度"——团队空时必须先 team_building,其次考虑组织结构审查,最后才是运行态。单测覆盖所有 transition:
| from → to | 条件 | 覆盖点 |
|----------|------|-------|
| team_building → awaiting_hire | 提交 hire approval | unit |
| awaiting_hire → team_building | hire approval 被拒 | unit |
| awaiting_hire → operating | hire approval 通过 | unit |
| operating → awaiting_org | org review 触发 | unit |
| awaiting_org → operating | org review 通过 | unit |
## 附:proactive-footer 重组说明
`ceo-modes/proactive-footer.en.md` 当前存在于 ceo-modes 目录但不是 mode。重组:
```
Before:
ceo-modes/
├── awaiting-hire.en.md
├── awaiting-org.en.md
├── operating.en.md
├── proactive-footer.en.md ← 伪 mode
└── team-building.en.md
After:
ceo-modes/ ← 真 modes
├── awaiting-hire.en.md
├── awaiting-org.en.md
├── operating.en.md
└── team-building.en.md
ceo-shared/ ← 跨 mode 共享片段
└── proactive-footer.en.md
```
`system-prompt.ts` 加载 mode 片段后,独立拼接 `ceo-shared/proactive-footer.en.md`。
@@ -0,0 +1,250 @@
# Proposal M8: worker/ 13 文件整合为 3 文件(entry / runtime / observability)
> 来源:问题.md M8(行 266-270)、附录 C(行 396-397)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:1 周
> Blocking dependencies:无
## 问题陈述
`src/main/worker/` 当前 13 个文件:
| 文件 | 行数近似 | 职责 |
|------|---------|------|
| circuit-breaker.ts | 80+ | run 失败熔断 |
| failure-escalation.ts | 60+ | 失败升级通知 |
| log-redactor.ts | 50+ | log 敏感信息遮蔽 |
| pi-agent-factory.ts | 131 | 组装 pi agent |
| run-event-recorder.ts | 100+ | run event 写入 DB |
| run-log-filter.ts | 40+ | log 过滤 |
| worker-crash-recovery.ts | 80+ | 崩溃恢复 |
| worker-entry.ts | 50+ | 进程入口 |
| worker-helpers.ts | 100+ | 杂项 utility |
| worker-message-handler.ts | 150+ | IPC 消息分发 |
| worker-rpc.ts | 60+ | RPC 框架 |
| worker-runner.ts | 239 | 主 run loop |
| worker-types.ts | 80+ | 类型定义 |
问题:
1. **边界模糊**:`circuit-breaker` / `failure-escalation` / `log-redactor` / `run-log-filter` 四个"辅助"模块互相引用,存在**跨文件 silent dependency**(比如 failure-escalation 读 circuit-breaker 的状态)
2. **helpers 是垃圾筐**:`worker-helpers.ts` 什么都有——典型的 catch-all 反模式
3. **13 个文件对 ~1200 行总量是过度切分**:平均每个文件 ~90 行,跨文件跳转成本高
4. **职责不在文件名上**:`worker-entry.ts` / `worker-runner.ts` / `worker-message-handler.ts` 三个都像"入口",实际职责谁是谁需要打开文件才知道
## 根因分析
Linus 视角(第七条:解法是否最简):**一件事切十三片是过度工程**。
正确的切分标准不是"这段代码看起来像独立功能",而是"这段代码的变更频率和变更原因与其他代码是否不同"。当前 worker 的所有文件几乎**同一批人、同一时间、同一原因**被修改——每次 pi-agent-core 升级或 bug fix 都在改多个文件。这表明它们本质是**一个模块**被过早分裂。
Karpathy 视角补充:worker 是 agent 跑 run 的核心路径,逻辑密度高——分文件反而让跟踪执行流程更难("log redactor 在 worker-entry 之后还是之前跑的?")。
## 设计方案
### 新结构:3 文件 + 1 types
```
src/main/worker/
├── worker-types.ts # 保留 (类型定义)
├── worker-entry.ts # 进程入口 + crash recovery + message dispatch
├── worker-runtime.ts # pi agent 组装 + run loop + circuit breaker + failure escalation
└── worker-observability.ts # log redactor + log filter + run-event recorder
```
### 职责划分
**worker-entry.ts**(原 worker-entry + worker-message-handler + worker-rpc + worker-crash-recovery + worker-helpers 里的入口相关)
- Electron utility process entry point
- 初始化 IPC(与 main 的 duplex channel)
- 接收 inbound messages、分发到 runtime
- 处理崩溃恢复(重启后 resume run state)
- 进程级别 helper
**worker-runtime.ts**(原 worker-runner + pi-agent-factory + circuit-breaker + failure-escalation)
- 组装 pi agent(hooks / context compactor / cost writer 等)
- 启动和驱动 agent loop
- 失败检测(circuit breaker)
- 失败升级(重试策略 / 通知 main)
- 这些是**紧密耦合**的——一个 run 的生命周期线性经过它们
**worker-observability.ts**(原 log-redactor + run-log-filter + run-event-recorder)
- Log 敏感信息遮蔽
- Log level 过滤
- Run event 持久化到 DB
- 这些都是"run 执行的副作用输出",职责统一
**worker-types.ts**(不动)
- 保留所有跨文件共享的类型
### 单文件结构建议
每个合并后的文件内部用 section comment 分 region:
```ts
// worker-runtime.ts
// ============================================================================
// pi agent factory
// ============================================================================
export function createPiAgent(...) { ... }
// ============================================================================
// Circuit breaker
// ============================================================================
class CircuitBreaker { ... }
// ============================================================================
// Run loop
// ============================================================================
export async function runWorker(...) {
const agent = createPiAgent(...);
const breaker = new CircuitBreaker(...);
// ...
}
```
这样读者打开一个文件就能线性看完 run 的全生命周期,不用跳 5 个文件。
## Migration 计划
1. **Phase 1(1 天)**:创建新 3 文件空壳,逐步把内容从 13 个旧文件搬过去
2. **Phase 2(2 天)**:搬运过程中修 import path——外部消费者(service-graph / orchestration)的 import 从 13 个路径收敛到 3 个
3. **Phase 3(1 天)**:删除旧 13 个文件
4. **Phase 4(1 天)**:`pnpm typecheck` + 相关单测跑完
5. **Phase 5(1 天)**:观察 log output 是否有 regression(redactor / filter 的行为是否保持)
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| 循环 import | 合并后某些函数互相依赖 | 合并过程本就在消除循环,如发现循环则进一步合并或拆新文件 |
| Log redactor 规则失效 | 合并时漏搬某个 regex | 搬前先列完整 redact rule 清单,合并后 diff |
| Circuit breaker 状态丢失 | 重构过程中某状态字段遗漏 | 单测覆盖 circuit breaker 开/合/半开三态 |
| 外部 consumer 的 import 路径 | service-graph 引用 13 个 worker 文件中某些 | 先全改 import,再删旧文件 |
**回滚路径**:用 `git mv` 做文件合并,所有改动在一个大 PR,如发现问题 revert 一键回到 13 文件结构。
## 验证标准
1. **Typecheck 全绿**:pnpm typecheck 通过
2. **Worker 启动正常**:desktop app 启动后能看到 worker utility process 正常上线
3. **Run 执行完整**:手动跑一个 run,验证 tool call / log / cost 记录都正确
4. **Log 行为不变**:同样的 agent 输出,redacted log 应和 migration 前字节级一致
5. **Crash recovery 可用**:kill worker 进程,主进程重启 worker 后能 resume 运行中的 run
## 不在范围
- **不改 worker 行为**:只是文件重组,功能完全一致
- **不重写 pi-agent-factory 内部逻辑**:hook 注册顺序和参数都保留
- **不改 IPC 协议**:main ↔ worker 的消息格式不动
- **不引入新的 utility process**:browser automation 的独立 utility process 走 M9
- **不拆分 worker 到独立 package**:当前仓库内结构即可
## 参考文件清单
合并前 13 个文件:
- `src/main/worker/circuit-breaker.ts`
- `src/main/worker/failure-escalation.ts`
- `src/main/worker/log-redactor.ts`
- `src/main/worker/pi-agent-factory.ts`
- `src/main/worker/run-event-recorder.ts`
- `src/main/worker/run-log-filter.ts`
- `src/main/worker/worker-crash-recovery.ts`
- `src/main/worker/worker-entry.ts`
- `src/main/worker/worker-helpers.ts`
- `src/main/worker/worker-message-handler.ts`
- `src/main/worker/worker-rpc.ts`
- `src/main/worker/worker-runner.ts`
- `src/main/worker/worker-types.ts`
外部消费者(import 路径需更新):
- `src/main/service-graph.ts`
- `src/main/orchestration/scheduler.ts`
- `src/main/orchestration/task-runner.ts`
## 附:为什么现在修
文件数量的治理成本**线性增长**——13 个文件比 3 个文件多 10 倍"哪个文件负责什么"的认知负担。每次新 feature 加 worker 里的 hook 或 recovery 逻辑,都要先判断"应该加到哪个文件"。
**AI coding agent 尤其受影响**——agent 写代码前要扫完 13 个文件才知道 worker 现有结构。合并到 3 个文件后,一次读完就能动手。这直接缩短每个 worker 相关任务的 time-to-PR。
M8 和 M10(agent-runtime hooks 聚合)有类似 pattern——都是"过度切分的模块要合回来"。两件事可以同一个 sprint 做。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | 新 3 文件结构覆盖旧 13 文件所有导出符号 | Linus |
| G2 | 外部 import 路径全部更新(grep `from "\.\./worker/"` 只出现 3 个模块名) | Linus |
| G3 | worker 启动 / run 执行 / crash recovery 三大路径验证 | Owner |
| G4 | Log output 字节级 diff 与合并前一致 | Karpathy |
| G5 | Lines per file < 800(可读性上限) | Linus |
## 附:文件内部 region 结构
**worker-runtime.ts** 的 section 顺序:
```
// ============================================================================
// Circuit breaker (state machine: closed / open / half-open)
// ============================================================================
// ============================================================================
// Failure escalation (retry policy + incident creation)
// ============================================================================
// ============================================================================
// Pi agent factory (assemble hooks + options)
// ============================================================================
// ============================================================================
// Run loop (main entry: runWorker)
// ============================================================================
```
读者按顺序线性理解一个 run 的生命周期:通过 circuit breaker 检查 → 构造 agent → 进 run loop → 失败时走 escalation。
## 附:反模式对比
**Before**:
- `worker-runner.ts` 引用 `circuit-breaker.ts`、`failure-escalation.ts`、`pi-agent-factory.ts`
- `pi-agent-factory.ts` 引用 `log-redactor.ts`
- `worker-crash-recovery.ts` 引用 `run-event-recorder.ts`
- 13 文件组成不可见的依赖图
**After**:
- 3 文件 + 1 types,依赖方向清晰:`entry.ts → runtime.ts → observability.ts`
- 单向依赖,无循环
- 新 reader 打开 `runtime.ts` 就能读懂 run 怎么跑
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:合并到 3 文件。
**方案 B(保留文件但拆到 5-6 个更合理的模块)**:仍然分散,没解决根本问题。
**方案 C(全合到单一 worker.ts 大文件)**:过度合并,单文件过大(>2000 行)不利于 review。
方案 A 平衡了"单 responsibility 原则"和"可读性"。
## 附:合并映射表
| 原文件 | 合并到 | 原职责 |
|-------|-------|-------|
| worker-entry.ts | worker-entry.ts | 进程入口 |
| worker-crash-recovery.ts | worker-entry.ts | Crash 恢复 |
| worker-message-handler.ts | worker-entry.ts | IPC 分发 |
| worker-rpc.ts | worker-entry.ts | RPC 框架 |
| worker-helpers.ts | worker-entry.ts + worker-runtime.ts | 杂项(分拆归属) |
| worker-runner.ts | worker-runtime.ts | Run loop |
| pi-agent-factory.ts | worker-runtime.ts | Agent 组装 |
| circuit-breaker.ts | worker-runtime.ts | 熔断 |
| failure-escalation.ts | worker-runtime.ts | 失败升级 |
| log-redactor.ts | worker-observability.ts | Redact |
| run-log-filter.ts | worker-observability.ts | Filter |
| run-event-recorder.ts | worker-observability.ts | Event 记录 |
| worker-types.ts | worker-types.ts | 不变 |
合并后 ~1200 行分布:entry ~400, runtime ~500, observability ~200, types ~100。
@@ -0,0 +1,268 @@
# Proposal M9: browser / automation 拆出独立 utility process
> 来源:问题.md M9(行 272-276)、附录 I 第 3 条(行 589-590)
> 状态:proposed
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
> Owner: TBD
> 估计工时:3 周
> Blocking dependencies:C2(RuntimeBus 就绪后,跨进程 dispatch 更容易)
## 问题陈述
`src/main/browser/` 和 `src/main/automation/` 两个子系统目前运行在主进程:
- `browser/` 跑 Electron 的 BrowserWindow / BrowserView,做网页自动化(填表单、点按钮、截图)
- `automation/` 跑 rule engine,触发 browser actions 或其他 side effects
后果:
1. **Crash blast radius 大**:browser page crash / 内存泄漏直接拖垮 main 进程,整个 desktop app 挂掉
2. **事件循环阻塞**:自动化脚本跑长时间同步操作(例如等待 CAPTCHA)时,main 进程 UI IPC 都受影响
3. **Cloudflare / CAPTCHA 等防爬虫对抗**:反爬逻辑跑在 main 进程意味着主程序有"机器人"指纹,可能被下游服务整体封禁
4. **资源占用不可见**:browser 内存和 CPU 混在 main 进程里,不便于监控 / 限额
5. **Attack surface**:自动化脚本触达外部网站,一旦被攻击(XSS / sandbox escape)直接污染 main 进程的数据库连接和文件系统权限
## 根因分析
Linus 视角:**crash blast radius 是进程边界的第一目的**。把 browser 跑在 main 进程 = 把不稳定的组件和稳定的组件绑一起了。Electron 提供 utility process 正是为此——crash 就让它 crash,主进程重启它即可。
当前架构把"永远不 crash 的组件(main)"和"经常 crash 的组件(browser automation)"放在同一个进程里——这是 colocation 反模式。
Karpathy 视角:agent 跑 browser automation 是典型的 stochastic task——成功率不是 100%。需要**独立的失败域 + 重试机制 + resource cap**。跑在 main 进程无法给 browser 设单独的 memory limit / CPU quota。
## 设计方案
### 架构
```
Main process
├── Orchestration / DB / IPC to renderer
│
├──[fork]──> Worker utility process (agent run loop)
│
├──[fork]──> Browser utility process (browser automation)
│ ├── Electron BrowserWindow / Puppeteer
│ ├── 独立内存 / CPU quota
│ └── 通过 bus dispatch "browserAction.dispatch" 消费
│
└──[fork]──> Automation utility process (rule engine)
├── Rule evaluation loop
├── Scheduled triggers
└── 通过 bus dispatch 发送到 browser / agent
```
### 对 C2 bus 的依赖
本 proposal 依赖 C2 的 RuntimeBus——拆进程后跨进程 dispatch 最自然的实现是:
- Main 的 bus 是 hub
- utility process 和 main 之间通过 `process.parentPort.postMessage` 传递 command/event
- Bus 在 main 侧做 multiplexing:根据 command kind 路由到对应 utility process
这让 C2 的抽象同时覆盖 in-process 和 cross-process 两种 dispatch,**代码层面完全一致**。
### Browser utility process 实现
新建 `src/main/browser-process/entry.ts`:
```ts
import { parentPort } from "node:worker_threads"; // 或 utilityProcess API
const browser = new BrowserAutomationService(...);
parentPort.on("message", async (msg) => {
if (msg.kind === "browserAction.dispatch") {
try {
const result = await browser.execute(msg.payload);
parentPort.postMessage({ kind: "browserAction.result", runId: msg.runId, result });
} catch (e) {
parentPort.postMessage({ kind: "browserAction.error", runId: msg.runId, error: serializeError(e) });
}
}
});
```
### Automation utility process
rule engine 比 browser 轻量,但同样的道理——独立进程 = 独立故障域。新建 `src/main/automation-process/entry.ts`:
```ts
const engine = new AutomationRuleEngine(db);
engine.on("fire", (payload) => {
parentPort.postMessage({ kind: "automation.fired", ...payload });
});
// tick loop
setInterval(() => engine.evaluate(), 1000);
```
### DB 访问
utility process **不直接访问 DB**——所有 DB 操作通过 bus 让 main 进程的 writer actor 处理(与 B4 的 single-writer actor 配合)。
这避免 SQLite 跨进程连接的复杂度(SQLite 支持跨进程但要处理 WAL 同步)。
### Resource cap
- Electron utility process 可设 `memoryLimit`(v27+)
- 配置 `maxOldSpaceSize` 控制 V8 heap
- Main 进程定期 ping utility process,超时自动 kill + restart
### Crash recovery
Browser utility process crash 时:
1. Main 收到 `process.exit` 事件
2. 发"browser 不可用"告警
3. 根据 restart policy 重启进程
4. 重启后已在运行的 browser action 状态从 DB 恢复
Automation utility process 同理。
## Migration 计划
1. **Phase 0(前置)**:C2 RuntimeBus 完成
2. **Phase 1(1 周)**:设计 cross-process bus 协议(message envelope / error serialization / backpressure)
3. **Phase 2(1 周)**:browser 拆进程——browser automation 代码搬到 `browser-process/`,main 侧改为通过 bus dispatch
4. **Phase 3(3 天)**:automation 拆进程——同样方式
5. **Phase 4(3 天)**:monitoring & restart policy—— main 侧加 utility process health check + telemetry
6. **Phase 5(1 周观察期)**:生产压力测试 / crash 注入测试,确认 blast radius 隔离
## 风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|------|---------|---------|
| IPC latency 升高 | browserAction 从本地函数调用变成跨进程 | 每次 action 多 1-2ms 可忽略;如果某 action 需要高频通信则重新评估 |
| State 丢失 | utility process crash 时正在执行的 action 状态 | 所有 action 状态进 DB 持久化,crash 后恢复 |
| 开发体验差 | 调试需要 attach 多个进程 | VS Code 多 process debug configuration |
| macOS entitlements | utility process 需要特殊 entitlement 才能跑某些 Electron API | 研究 Apple 文档,提前在 entitlements.plist 加 |
**回滚路径**:拆进程过程保留旧 in-process 代码路径作为 feature flag(`BROWSER_USE_UTILITY_PROCESS=true/false`),回滚时切 flag。
## 验证标准
1. **Blast radius 验证**:故意在 browser utility process 内 `process.exit(1)`,main 进程应不受影响,UI 仍可用
2. **Memory isolation**:browser automation 跑长时间任务,main 进程 memory 稳定不增长
3. **Restart 自动化**:kill -9 browser process,main 检测到后自动重启,丢失 ≤1 个 in-flight action
4. **Latency baseline**:browserAction.dispatch 的 P99 latency 在可接受区间(< 50ms 额外开销)
5. **Crash rate telemetry**:生产观察一周,browser process crash 数据可见且独立于 main 进程崩溃
## 不在范围
- **不把 worker 也一起重构**:worker 已经是 utility process(见 worker-entry.ts),本 proposal 只补 browser / automation
- **不实现跨机器 dispatch**:bus 仍是单机进程间,不做分布式
- **不重构 rule engine 的规则 DSL**:只是搬进程
- **不做 browser farm**:单个 browser utility process 就够,不是并发 N 个实例
- **不改 renderer**:renderer 层对 browser / automation 的交互仍走 main → utility process 中转
## 参考文件清单
- `src/main/browser/`(当前 browser automation 代码)
- `src/main/automation/`(当前 automation rule engine 代码)
- `src/main/automation-service.ts`(当前 rule engine 入口)
- `src/main/browser/browser-action-service.ts`(action 执行)
- `src/main/browser/browser-action-executor.ts`(具体 action)
- `src/main/service-graph.ts:110-113`(browserActionRef / automationRef 的 LateBinding,C2 会改)
- Electron utilityProcess 文档:https://www.electronjs.org/docs/latest/api/utility-process
## 附:为什么现在修
单个 browser page 的崩溃已经是生产痛点——任何 agent 的网页操作触发 chromium crash 都拖垮用户整个 desktop app。随着 agent 被用于更多 web automation 场景(社交登录、paid subscription 填表、CAPTCHA 对抗),crash 频率只会升。
C2 做完后,跨进程 bus 成本极低——本 proposal 是把 C2 的 bus 抽象延伸到进程边界,很自然。
Jobs 视角:**"app 不能崩"是 desktop 软件的基础承诺**。当前架构把 desktop 稳定性和 agent browser 稳定性绑定了,这违反了承诺。
## 附:gating matrix
| Gate | 条件 | 放行判断人 |
|------|------|----------|
| G1 | C2 RuntimeBus 完成 + 跨进程 dispatch 协议定稿 | Linus |
| G2 | browser utility process 跑 long-running automation 1 小时无 main 进程回压 | Owner |
| G3 | Kill utility process 后 main 进程自动重启,in-flight action 损失 ≤ 1 | Owner |
| G4 | Browser process 内存隔离验证:独立 heap,不与 main 共享 | Linus |
| G5 | macOS entitlements 配置完整,通过 notarization | Owner |
## 附:跨进程 message envelope
```ts
interface CrossProcessMessage {
id: string; // correlation id
kind: CommandKind | EventKind;
payload: unknown; // 由 kind 决定 shape
source: "main" | "browser-process" | "automation-process" | "worker";
timestamp: number;
// Error case
error?: { name: string; message: string; stack?: string };
}
```
序列化走 JSON(Electron utilityProcess 默认);大 payload 用 MessagePort + structured clone。
## 附:跨进程 DB 访问禁令
Utility process 内禁止直接 `new DatabaseSync(...)` ——所有 DB 操作走 bus dispatch 到 main 的 writer actor(B4)。原因:
1. SQLite WAL 跨进程同步复杂
2. Main 的 writer actor 保持单 writer 不变式
3. Utility process crash 不会导致 DB 文件锁残留
## 附:反模式对比
**Before**:
- Main 进程 import browser automation service
- 浏览器 crash → main 进程 crash → desktop app 关闭
- 用户 log off,30+ 秒后重新登录才能恢复
**After**:
- Main 进程通过 bus dispatch 到 browser utility process
- 浏览器 crash → utility process 独立退出 → main 进程 2 秒内重启它
- 用户看到 "browser automation recovering",UI 其他部分无感
## 附:为什么不选其他方案
**方案 A(当前 proposal)**:Electron utilityProcess 隔离。
**方案 B(完全拆出 desktop app 外,跑独立 daemon)**:过度工程——用户不希望后台跑多个进程。
**方案 C(保留 main 进程但加 try-catch)**:JS 层的 try-catch 无法 catch native crash(chromium renderer crash)。
方案 A 是 Electron 官方推荐的隔离路径。
## 附:restart policy 细节
| 情况 | 行为 |
|-----|------|
| Browser utility process 正常退出(exit 0) | 不重启(可能是主动 stop) |
| 异常退出(exit ≠ 0) | 立即重启,最多 3 次/分钟 |
| 超过 3 次/分钟 | 进入 backoff(冷却 5 分钟) |
| 冷却期间 browserAction.dispatch | 直接返回 `BROWSER_UNAVAILABLE` 错误 |
| 冷却结束 | 自动重启;成功则清 backoff 计数 |
| 连续 3 次 backoff 失败 | 告警 + 停止重启,等用户手动恢复 |
## 附:resource cap 配置
```ts
const browserProcess = utilityProcess.fork(entryPath, args, {
serviceName: "agent-company-browser",
stdio: "pipe",
execArgv: ["--max-old-space-size=2048"], // 2GB heap 上限
});
```
macOS 的 resource monitor 可以看到 `agent-company-browser` 的 CPU/memory 独立使用,用户也能直接感知 automation 占用。
## 附:与 C2 的跨进程扩展
C2 的 InProcessRuntimeBus 扩展为支持 cross-process:
```ts
class CrossProcessRuntimeBus implements RuntimeBus {
constructor(private child: UtilityProcess, private local: InProcessRuntimeBus) {}
async dispatch(kind, payload) {
if (this.isLocalKind(kind)) {
return this.local.dispatch(kind, payload);
}
return this.sendToChild(kind, payload);
}
}
```
消费者代码不变——bus 抽象隐藏了进程边界。