① 现状梳理 · 既有 LemonBoss 链路
LemonBoss 已经有完整的 ACP / Master Agent / SessionPool 链路:
关键 insight:DevLoop 的 LoopController 不需要新建 agent-manager 等价物。它复用既有 agent-manager.callAgent,只在上面套一层「持久化包装」(P-8 的 callAgentWithPersistence)。
② callAgentWithPersistence 的完整实现
这是 LoopController 调任何 Agent 的唯一入口。它包住 agent-manager.callAgent 同时管 M-10/M-12/M-13。
// server/src/core/loop-controller.js · 节选 const { agentManager } = require('./agent-manager'); const { unitRegistry } = require('../../services/unit-registry'); const { docStore } = require('../../services/doc-store'); const { bugTracker } = require('../../services/bug-tracker'); const { sseBroker } = require('../../services/sse-broker'); const { timelineLogger } = require('../../services/timeline-logger'); const { loadAllTools } = require('../tools/tool-registry'); const toolRegistry = loadAllTools(); // 单例 async function callAgentWithPersistence(name, input) { const { pid, unit_id } = input; const call_id = genCallId(); const startedAt = Date.now(); // 1. 注入运行态 (M-10) await writeRuntimeState(pid, name, { agent: name, status: 'running', call_id, unit_id, current_step: 0, total_steps: 0, started_at: new Date().toISOString(), }); timelineLogger.append(pid, { source: 'system', kind: 'agent_started', ref: `agent/${name}/${call_id}`, summary: `${name} started`, }); sseBroker.emit(pid, 'agent.started', { agent: name, call_id, unit_id }); // 2. 注入 memory + 构建 prompt const systemPrompt = await buildPromptWithMemory(name, pid); // 3. 构建 ctx (传给所有工具的运行时上下文) const ctx = { pid, agent: name, unit_id, call_id, loopController: module.exports, docStore, unitRegistry, bugTracker, timelineLogger, sseBroker, companyDir: companyDirOf(pid), projectDir: projectsDir(pid), unitDir: unit_id ? unitDirOf(pid, unit_id) : null, }; // 4. 调既有 agent-manager (这是关键的"桥"那一行) let result; try { result = await agentManager.callAgent({ agentName: `devloop-${name}`, // 与 _registry.json 中 id 对齐 client: companyDirOf(pid), // SessionPool key 之一 deploy: pid, // 另一个 key workDir: ctx.unitDir || ctx.projectDir, systemPrompt, input, // 透传 PMInput / ArchitectInput / etc toolPolicy: getToolPolicy(name), // 从 _registry.json 读 toolRegistry, toolCtx: ctx, // 关键:工具上下文注入 timeoutMs: getTimeout(name), // PM 10min,Coder 5min, etc onChunk: (chunk) => { // 流式回调 sseBroker.emit(pid, 'agent.stream', { agent: name, unit_id, chunk }); }, }); // 5. 成功收尾 await writeRuntimeState(pid, name, { status: 'done', last_output_ref: await snapshot(result, pid, call_id), updated_at: new Date().toISOString(), }); timelineLogger.append(pid, { source: name, kind: 'agent_done', ref: `agent/${name}/${call_id}`, summary: `${name} done in ${Date.now() - startedAt}ms`, payload: { tokens: result.tokens_used }, }); sseBroker.emit(pid, 'agent.done', { agent: name, call_id, ...result }); return result; } catch (e) { // 6. 失败处理 (P-8 状态机) await writeRuntimeState(pid, name, { status: 'error', error: { code: e.code || 'AGENT_CRASH', message: e.message, stack: e.stack }, updated_at: new Date().toISOString(), }); timelineLogger.append(pid, { source: 'system', kind: 'agent_error', ref: `agent/${name}/${call_id}`, summary: e.message, }); sseBroker.emit(pid, 'agent.error', { agent: name, call_id, code: e.code, message: e.message }); throw e; } }
③ agent-manager.callAgent 需要扩展的字段
LemonBoss 既有 agent-manager.callAgent 已经接受 agentName / client / deploy / workDir 等。要支持 DevLoop 需要追加 3 个字段:
| 新字段 | 类型 | 含义 | 改 agent-manager 的代码量 |
|---|---|---|---|
toolRegistry | Map<string, ToolDef> | 本次注册的 79 个工具(来自 loadAllTools) | +5 行:透传给 SessionPool → Protocol |
toolCtx | object | 工具调用时的 ctx(pid · sseBroker · etc) | +3 行:同上透传 |
onChunk | function | 流式回调(SSE 用) | +2 行:传给 stream-executor |
合计 ~10 行修改 server/src/core/agent-manager.js。不改 agent-manager 的 public API,所有新字段都是可选;不传时行为同既有。
④ SessionPool key 设计
既有 SessionPool key = (agentName, client, deploy, workDir)。DevLoop 把它映射成:
| SessionPool key 段 | DevLoop 填入 |
|---|---|
| agentName | devloop-pm / devloop-architect / ...(与 _registry.json id 一致) |
| client | data/companies/{cid}/ |
| deploy | {pid} |
| workDir | project / unit 目录 |
这样得到的复用粒度:
- 同
(company, project, agent)内 → ACP session 复用(多次 callAgent 上下文延续) - 跨 project → 独立 session(避免 PM 把 A 项目知识带进 B 项目)
- 同 project 不同 unit 的 Coder → 独立 session(避免 todo-be 的 prompt 污染 auth-be)
Coder/Tester 特殊:workDir 必须含 unit_id
Coder/Tester 的 workDir 是 units/{unit_id}/。SessionPool key 因此天然按 unit 隔离。意外效果:retry 同 unit 时复用 session(上下文连续),跨 unit 时不复用(互不干扰)。这正是想要的。
⑤ 流式输出 + tool_use 中断处理
Claude CLI 输出经 stream-executor 处理。DevLoop 需要的额外行为:
| 事件 | 原 stream-executor 行为 | DevLoop 加什么 |
|---|---|---|
| text chunk | 累积到 result.content | + onChunk 回调 → SSE agent.stream |
| tool_use 请求 | 路由到工具,执行后回 tool_result | + 工具内部可调 sseBroker(如 update_todo_item → SSE agent.step) |
| tool 失败 | 把错误打回给 Claude | + 标准化 ToolValidationError / ToolSandboxError,给 Claude 明确格式 |
| stop | 返回 final result | + 自动写 timeline.agent_done |
stream-executor 在 LemonBoss 已经处理大部分,DevLoop 主要是挂回调钩子。改动量小。
⑥ 错误传播链
// 错误 4 层传播
工具 execute throws ToolValidationError
↓
ToolRegistry 包装成 tool_result { is_error: true, content: "..." }
↓
Claude CLI 收到错,继续 reasoning → 可能重试或换路径
↓
若多次失败 / 超 budget → Claude stop → result.ok = false
↓
callAgentWithPersistence catch → writeRuntimeState status=error
↓
LoopController 决定 retry / block / 升 L3
这个链路在 P-8 Agent State 持久化 描述了行为,本页是实现细节。
⑦ 性能考量
| 项 | 关注点 | 数字 / 缓解 |
|---|---|---|
| Claude CLI 冷启动 | 每个 ACP session 第一次 ~1.5s | SessionPool 复用让大部分调用是热的 |
| tool_use 往返延迟 | 每次工具调用 ~10-30ms(stdio JSON-RPC) | 合理;高频工具(update_todo_item)不要每行都调 |
| system prompt 长度 | PM 主体 ~4KB + memory ~3KB = 7KB | OK,Claude 200K context 完全够 |
| 并发 session 数 | 每个 ACP session 占 ~100MB RAM + 1 个 Claude CLI 进程 | 单机建议 ≤ 10 并发;项目内并行 ≤ 5 |
| 磁盘 I/O | 工具大量小文件写(agent-state/ + timeline.ndjson) | 同步写 fsync,无 cache;项目目录有 ~100 文件就够大了 |
| SSE fan-out | 1 项目 1 个广播 | broker 内部 Set,O(订阅数) |
⑧ 安全边界
沙箱通过三层强制:
- ACP 进程层:Claude CLI 起在 cwd 锁定的子进程,不能 cd 出去
- 工具层:Write/Edit 工具检查 writeAllowList,越界抛 ToolSandboxError
- 系统层(兜底):定期 scanSandbox 扫描 mtime 在 call 周期内变化的文件,跨边界写直接拒收 result
// 第二层实现:Write 工具的 sandbox check module.exports.Write = { name: 'Write', ... async execute({ path: target, content }, ctx) { const abs = path.resolve(ctx.workDir, target); const allow = getAllowList(ctx.agent, ctx); // 来自 _registry.json if (!withinAllow(abs, allow)) throw new ToolSandboxError(target, ctx.agent); fs.writeFileSync(abs, content); return { ok: true, path: abs }; } };
⑨ 一行总结
⑩ 关联
- Tool Registry · 工具层细节
- P-8 Agent State 持久化 · 包装层为啥这么写
- LemonBoss 映射 · 既有 session-pool / agent-manager / scheduler 怎么 REUSE