From 06d58d9fd6146f1f0080d2ad070c6e584fcf15f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 10:53:16 -0400 Subject: [PATCH] Add proposals for deferred root-cause refactors (B2/B4/C1/C2/C4/C7/M2/M5/M7-M12) --- .gitignore | 1 + docs/proposals/B2-tasks-state-machine.md | 256 ++++++++++++++++ docs/proposals/B4-single-writer-actor.md | 252 ++++++++++++++++ .../C1-appdatabase-passthrough-removal.md | 267 +++++++++++++++++ .../proposals/C2-service-graph-runtime-bus.md | 260 ++++++++++++++++ .../C4-initial-user-turn-semantic-cue.md | 250 ++++++++++++++++ .../C7-prompt-builder-conditional-assembly.md | 280 +++++++++++++++++ .../M10-agent-runtime-hooks-aggregation.md | 264 ++++++++++++++++ .../M11-credential-manager-unification.md | 251 ++++++++++++++++ docs/proposals/M12-renderer-state-audit.md | 252 ++++++++++++++++ docs/proposals/M2-ipc-contracts.md | 258 ++++++++++++++++ docs/proposals/M5-monorepo-split.md | 281 ++++++++++++++++++ docs/proposals/M7-ceo-mode-state-machine.md | 270 +++++++++++++++++ .../M8-worker-module-consolidation.md | 250 ++++++++++++++++ .../M9-browser-automation-utility-process.md | 268 +++++++++++++++++ 15 files changed, 3660 insertions(+) create mode 100644 docs/proposals/B2-tasks-state-machine.md create mode 100644 docs/proposals/B4-single-writer-actor.md create mode 100644 docs/proposals/C1-appdatabase-passthrough-removal.md create mode 100644 docs/proposals/C2-service-graph-runtime-bus.md create mode 100644 docs/proposals/C4-initial-user-turn-semantic-cue.md create mode 100644 docs/proposals/C7-prompt-builder-conditional-assembly.md create mode 100644 docs/proposals/M10-agent-runtime-hooks-aggregation.md create mode 100644 docs/proposals/M11-credential-manager-unification.md create mode 100644 docs/proposals/M12-renderer-state-audit.md create mode 100644 docs/proposals/M2-ipc-contracts.md create mode 100644 docs/proposals/M5-monorepo-split.md create mode 100644 docs/proposals/M7-ceo-mode-state-machine.md create mode 100644 docs/proposals/M8-worker-module-consolidation.md create mode 100644 docs/proposals/M9-browser-automation-utility-process.md diff --git a/.gitignore b/.gitignore index cb1621da..19c95a63 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ oldreference/ *.md !/README.md !/docs/*.md +!/docs/proposals/*.md !/CLAUDE.md !/.planning/**/*.md !/src/main/prompt-builder/**/*.md diff --git a/docs/proposals/B2-tasks-state-machine.md b/docs/proposals/B2-tasks-state-machine.md new file mode 100644 index 00000000..9a2071bb --- /dev/null +++ b/docs/proposals/B2-tasks-state-machine.md @@ -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。 diff --git a/docs/proposals/B4-single-writer-actor.md b/docs/proposals/B4-single-writer-actor.md new file mode 100644 index 00000000..ead840cc --- /dev/null +++ b/docs/proposals/B4-single-writer-actor.md @@ -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; + +class WriterActor { + private queue: Array<{ job: WriteJob; resolve: (v: unknown) => void; reject: (e: unknown) => void }> = []; + private running = false; + private db: DatabaseSync; // 独占的 writer 连接 + + async enqueue(job: WriteJob): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ job: job as WriteJob, 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 成本最低收益最高。 diff --git a/docs/proposals/C1-appdatabase-passthrough-removal.md b/docs/proposals/C1-appdatabase-passthrough-removal.md new file mode 100644 index 00000000..3e4736f2 --- /dev/null +++ b/docs/proposals/C1-appdatabase-passthrough-removal.md @@ -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.(...)`,替换为 `db..(...)`。 +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 决定是否采纳。 diff --git a/docs/proposals/C2-service-graph-runtime-bus.md b/docs/proposals/C2-service-graph-runtime-bus.md new file mode 100644 index 00000000..dde055f2 --- /dev/null +++ b/docs/proposals/C2-service-graph-runtime-bus.md @@ -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(kind: K, payload: CommandPayload): Promise>; + + // 订阅特定命令类型(service 注册自己能处理什么) + subscribe(kind: K, handler: CommandHandler): () => void; + + // 事件广播(1 对 N,无 ack) + emit(kind: K, payload: EventPayload): void; + + // 事件订阅 + on(kind: K, listener: EventListener): () => 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>(); + private listeners = new Map>>(); + + subscribe(kind: K, handler: CommandHandler): () => 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(kind: K, payload: CommandPayload): Promise> { + 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 | diff --git a/docs/proposals/C4-initial-user-turn-semantic-cue.md b/docs/proposals/C4-initial-user-turn-semantic-cue.md new file mode 100644 index 00000000..e76faddf --- /dev/null +++ b/docs/proposals/C4-initial-user-turn-semantic-cue.md @@ -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 平衡了清晰度和表达力。 diff --git a/docs/proposals/C7-prompt-builder-conditional-assembly.md b/docs/proposals/C7-prompt-builder-conditional-assembly.md new file mode 100644 index 00000000..5e0d9ad0 --- /dev/null +++ b/docs/proposals/C7-prompt-builder-conditional-assembly.md @@ -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 集合。 diff --git a/docs/proposals/M10-agent-runtime-hooks-aggregation.md b/docs/proposals/M10-agent-runtime-hooks-aggregation.md new file mode 100644 index 00000000..7eb0d6f7 --- /dev/null +++ b/docs/proposals/M10-agent-runtime-hooks-aggregation.md @@ -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(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 | diff --git a/docs/proposals/M11-credential-manager-unification.md b/docs/proposals/M11-credential-manager-unification.md new file mode 100644 index 00000000..ed6e9b7a --- /dev/null +++ b/docs/proposals/M11-credential-manager-unification.md @@ -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 { + 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 { + // 统一的轮换入口 + } + + async revoke(connectorId: string): Promise { + // 统一的吊销入口 + } + + private async resolveOAuth(cred: Credential, opts?: { skipRefresh?: boolean }): Promise { + 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 { + const secret = await this.vault.decrypt(cred.id); + return { secret, metadata: cred }; + } + + private async resolveCloud(cred: Credential): Promise { + // 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 是唯一真正统一的方案。 diff --git a/docs/proposals/M12-renderer-state-audit.md b/docs/proposals/M12-renderer-state-audit.md new file mode 100644 index 00000000..df50f02a --- /dev/null +++ b/docs/proposals/M12-renderer-state-audit.md @@ -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 () + +## 🔴 Critical findings +### F-01: <具体问题> +- 文件:src/renderer/xxx.tsx:<行号> +- 重现:<最小重现步骤> +- 影响:<用户层面影响> +- 建议修复:<简短描述> + +## 🟠 Major findings +... + +## 🟡 Minor findings +... + +## Covered e2e invariants +1. +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 脚本会同时塞进这三层。 diff --git a/docs/proposals/M2-ipc-contracts.md b/docs/proposals/M2-ipc-contracts.md new file mode 100644 index 00000000..504a0889 --- /dev/null +++ b/docs/proposals/M2-ipc-contracts.md @@ -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 = z.infer; +export type IpcOutput = z.infer; +``` + +### main 侧:强制契约注册 + +```ts +// src/main/ipc-core-common.ts(改造) +export function registerIpcHandler( + channel: K, + handler: (input: IpcInput) => Promise> | IpcOutput +): 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( + channel: K, + input: IpcInput +): Promise> { + 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: `/`(小写 / 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(channel: K, payload: IpcEventPayload) { + const validated = IpcEvents[channel].parse(payload); + webContents.send(channel, validated); +} + +// renderer 侧 +subscribeEvent(channel: K, handler: (payload: IpcEventPayload) => void): () => void { + const wrapped = (_evt, raw) => handler(IpcEvents[channel].parse(raw)); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.off(channel, wrapped); +} +``` + +Event 和 invoke 走同一套契约风格,减少认知负担。 diff --git a/docs/proposals/M5-monorepo-split.md b/docs/proposals/M5-monorepo-split.md new file mode 100644 index 00000000..42611525 --- /dev/null +++ b/docs/proposals/M5-monorepo-split.md @@ -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,反之亦然。 diff --git a/docs/proposals/M7-ceo-mode-state-machine.md b/docs/proposals/M7-ceo-mode-state-machine.md new file mode 100644 index 00000000..09c32068 --- /dev/null +++ b/docs/proposals/M7-ceo-mode-state-machine.md @@ -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 = { + 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/.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`。 diff --git a/docs/proposals/M8-worker-module-consolidation.md b/docs/proposals/M8-worker-module-consolidation.md new file mode 100644 index 00000000..2382f6af --- /dev/null +++ b/docs/proposals/M8-worker-module-consolidation.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。 diff --git a/docs/proposals/M9-browser-automation-utility-process.md b/docs/proposals/M9-browser-automation-utility-process.md new file mode 100644 index 00000000..ad40e1c4 --- /dev/null +++ b/docs/proposals/M9-browser-automation-utility-process.md @@ -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 抽象隐藏了进程边界。