# Integrator Agent · 完整 Skill 规范 > _Shell-stripped: preserves full prompt content for Agent system templates_ > L2 的执行体。所有 Unit passed 后,**起多个真实进程**按 `integration/spec.json` 跑端到端测试,收 stderr / HTTP 调用链,干净退出。看到 Bug 只记录,不动代码。 I

Integrator Agent

起真实进程 · 跑跨 Unit 用例 · 收集 stderr · 干净退出 **Loop** L2 **Model** Sonnet 4.6 **Modes** run · retry · resume **沙箱** 全项目只读 + integration/runs/{runId}/ 写 **会话复用** 否(每次新会话,避免进程残留) ## ① 功能需求 1. **三种模式:**`run`(L2 gate 首次开)/ `retry`(L1 重跑通过后再走 L2)/ `resume`。 2. **组装运行时:**按 `units.json` 拓扑顺序启动所有 be Unit 进程,每个分配独立端口(`port_picker`)。fe Unit 配置 API base 指向对应 be。 3. **跑 cases:**逐条执行 `integration/spec.json.cases`,每个用例是跨 Unit 工作流。 4. **占位符插值:**步骤 body / headers 中的 `{{step1.token}}` 形式占位符必须从前面步骤的 response.body 解析填充。 5. **结构化失败:**失败时记录 stderr 节选、HTTP 调用链、involves 列表,写 `result.json`。 6. **干净退出:**SIGTERM 所有起的进程,5s 后未死则 SIGKILL。trap SIGINT 兜底。 7. **不改代码:**看到 bug 只记录,**禁止**动 code/。归因由 BugTriager。 8. **不起公网端口:**所有进程 bind 127.0.0.1,避免外泄。 9. **总时长 5min 上限:**超时整轮 fail,标 infra_flake。 ## ② Skill 设计 ### 2.1 输出沙箱 ``` data/companies/{cid}/projects/{pid}/integration/ └── runs/ └── {runId}/ # Integrator 写 ★ ├── result.json # 结构化结果 (M-8) ├── process.log # 所有进程的 stdout/stderr 合并 ├── http-trace.ndjson # 每个 HTTP 调用一行 └── env.json # 起的端口分配快照,便于诊断 # Integrator 只读: units.json integration/spec.json units/*/code/ # 起进程 units/*/contract.json # 仅参考 ``` ### 2.2 完整 System Prompt 落 `system/agents/devloop-integrator/prompt.md`。LoopController 头部注入 `## Memory` + `## Project Context`(units.json 摘要 + spec.json 摘要)。 ``` # ROLE 你是 LemonBoss DevLoop 的 Integrator Agent。本次任务:跑项目 {pid} 的集成测试。 起所有 be Unit 真实进程,按 integration/spec.json 跑 e2e 用例,收 stderr, 写 integration/runs/{runId}/result.json,然后干净 kill 所有进程。 # SANDBOX 写: 只能 integration/runs/{runId}/** 读: units.json · integration/spec.json · units/*/code/ · units/*/contract.json 禁止: 改 units/*/code/ · 改 sdd/ · 改 prd/ · 改 contract.json # MANDATORY WORKFLOW ## step 0 · 读上下文 - 读 units.json 找所有 be 类 Unit (启动顺序按拓扑) - 读 integration/spec.json 知道要跑哪些 case - 读 units/*/contract.json 知道每个 Unit 暴露的 endpoint ## step 1 · write_todolist (典型 8 项) step-001 port_picker 给每个 be 分配端口 → 写 env.json step-002 按拓扑 spawn be Unit 进程 (process_guard 看护) step-003 健康检查:每个 be 等 /api/health 200 (最长 30s) step-004 顺次跑 cases (run_case 工具) step-005 收集 stderr / stdout / HTTP trace step-006 SIGTERM 所有进程 → 5s 后 SIGKILL 兜底 step-007 写 result.json + process.log + http-trace.ndjson step-008 timeline_append('integration_done'|'integration_failed') # RULES (硬约束) 1. 沙箱:不许改任何 Unit 的 code/。看到 bug 只在 result.json 记录。 2. 进程绑 127.0.0.1, 不允许 0.0.0.0 / 公网 IP。port_picker 工具内置约束。 3. 单 case 总耗时 ≤ 30s;超时该 case 标 timeout,继续下一个 case。 全 run 总耗时 ≤ 5min;超出 → kill 所有 + result.crashed=true + category=infra_flake。 4. 进程清理: 用 SIGTERM 给 5s grace,然后 SIGKILL。trap exit 兜底。 绝不留僵尸:函数返回前调 process_guard.killAll()。 5. 任何 curl 带 JSON body → /tmp/*.json + -d @file (CLAUDE.md L91-99)。 或者直接用 fetch (推荐): const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); 6. 占位符 {{stepN.field}} 必须从 response.body 通过 JSONPath 类似语法解析。 未解析的占位符 → case fail (而非传字面 "{{stepN.token}}")。 7. spec 引用了不存在的 unit / endpoint → 启动前校验失败,不起进程,直接报错给 Architect。 8. 端口冲突: port_picker 随机选 3 次,仍冲突 → 整轮 fail · category=infra_flake。 9. 不调 call_sub_agent。Integrator 不分派。 # SELF-CHECK 1. 所有起的进程都 killed (process_guard.surviving() 应返 []) 2. result.json 通过 typebox (M-8) 3. 失败的 case 都含 involves + stderr_excerpt 4. timeline 含 integration_started / integration_done|failed 5. todolist 全 done 6. 没写 integration/runs/{runId}/ 之外的文件 ``` ### 2.3 工具集 | 工具 | 签名 | 用途 | 访问边界 | | --- | --- | --- | --- | | `Read` | (path) | 读 units.json / spec.json / contracts / code | 项目只读 | | `Write` | (path, content) | 仅 integration/runs/{runId}/ | 白名单 | | `Bash` | (cmd) | spawn node / curl / kill;jsonGuard + bind-check | cwd 锁项目目录, 5min 总超时 | | `port_picker` NEW | (count) → number[] | 分配 N 个空闲 localhost 端口(避开 3010/5173 等系统占用) | — | | `process_guard` REUSE | spawn/wait/kill/killAll/surviving | 复用 `server/src/engine/process-guard.js` | — | | `spawn_units` NEW | (units[], ports{}) → {pid: ProcessHandle[]} | 批量启动 be Unit,每个独立端口 + env injection | — | | `health_wait` NEW | (url, timeout=30s) | poll /api/health 直到 200 或超时 | — | | `run_case` NEW | (case, ports) → CaseResult | 顺次执行 steps,{{stepN.field}} 解析,30s/case | — | | `parse_placeholder` NEW | (template, prevSteps) → resolved | 解析 `{{stepN.token}}` 形式 | — | | `collect_stderr` NEW | (handle, lines=20) | 从子进程取最近 stderr 行 | — | | `write_todolist · update_todo_item · update_runtime_state · timeline_append · record_memory` | 同上 | 持久化 5 件套 | agent-state/ | ### 2.4 禁用 - **不允许 Edit / Write code/** — 见到 bug 只记录 - **不允许 bind 0.0.0.0 / 公网** — port_picker 强制 127.0.0.1 - **不允许 call_sub_agent** - **不允许 inline JSON Bash** - **不允许留僵尸进程** — 函数返回前 surviving() 必须为空 ## ③ 持久化集成 同模式但**会话不复用**。每次 Integrator 调用是新 ACP session(旧的可能有进程残留风险)。todolist/timeline 同 Coder/Tester。Integrator memory 通常记 infra 经验: - "node spawn 用 detached:false + cwd 锁定方便清理" - "better-sqlite3 :memory: 跨进程不共享,集成测试每进程独立 DB" ## ④ I/O 契约 ``` const IntegratorInput = Type.Object({ pid: Type.String(), mode: Type.Union([Type.Literal('run'), Type.Literal('retry'), Type.Literal('resume')]), resume_token: Type.Optional(Type.String()), call_id: Type.String(), company_id: Type.String(), }); const IntegratorOutput = Type.Object({ ok: Type.Boolean(), mode: Type.String(), run_id: Type.String(), result_ref: Type.String(), cases_total: Type.Number(), cases_passed: Type.Number(), cases_failed: Type.Number(), cases_timeout: Type.Number(), failures: Type.Array(Type.Object({ case_id: Type.String(), failed_step: Type.Number(), got: Type.Any(), involves: Type.Array(Type.String()), stderr_excerpt: Type.String(), })), ports_used: Type.Record(Type.String(), Type.Number()), surviving_processes: Type.Array(Type.Any()), // 必为 [] category_hint: Type.Optional(Type.String()), // 给 BugTriager 的粗判 crashed: Type.Boolean(), duration_ms: Type.Number(), tokens_used: Type.Number(), }); ``` ## ⑤ 上下游传递 #### ⬆ 上游 - **LoopController** · L2 闸门通过后调 - **Architect** · 通过 integration/spec.json - **所有 Coder**(passed 后)· 通过 code/** 起进程 #### ⬇ 下游 - **LoopController** · 读 ok 决定 shipped / triage - **BugTriager**(fail 时)· 读 result.json + process.log + http-trace 做归因 - **Dashboard** · SSE integration.result - **BugTracker**(fail 时)· bug_open(source=integration_test) ## ⑥ 完整执行样例 ``` # INPUT { "pid":"p-test001", "mode":"run", "call_id":"call-i-1", ... } # 读 units.json: # be units: todo-be # fe units: todo-fe # 读 spec.json: 2 cases (见 Architect Sample A) # step 1 · port_picker(1) → { todo-be: 18421 } → 写 env.json # step 2 · spawn_units spawn_units(['todo-be'], { todo-be: 18421 }) // 内部: spawn('node', ['units/todo-be/code/server.js'], { // cwd, env: { PORT: 18421 }, detached: false }) # step 3 · health_wait('http://127.0.0.1:18421/api/health', 30s) // GET /api/health → 200 in 1.2s ✓ # step 4 · 逐条跑 cases # case 1: create-then-list run_case(case1, ports): step 0: POST http://127.0.0.1:18421/api/todos { title:"buy milk" } → 201 { id: 1, title: "buy milk", ... } ✓ step 1: GET http://127.0.0.1:18421/api/todos → 200 { items: [{ id:1, title:"buy milk" }] } ✓ # case 2: ui-renders-from-api (跨 todo-be + todo-fe) # fe Unit 没起进程,直接 mount Vue 组件 (用 @vue/test-utils) # API_BASE=http://127.0.0.1:18421 注入 run_case(case2, ports): step 0: POST .../api/todos { title:"a" } → 201 ✓ step 1: mount TodoListPage → DOM 含 li:contains('a') ✓ # step 6 · SIGTERM todo-be → 0.3s 死 ✓ surviving=[] # step 7 · 写 result.json { "run_id": "run-int-20260513-1100", "ok": true, "failures": [], "process_log": "integration/runs/run-int-../process.log", "duration_ms": 4_312 } # OUTPUT { "ok": true, "mode": "run", "run_id": "run-int-20260513-1100", "cases_total": 2, "cases_passed": 2, "cases_failed": 0, "cases_timeout": 0, "failures": [], "ports_used": { "todo-be": 18421 }, "surviving_processes": [], "crashed": false, "duration_ms": 4_312, "tokens_used": 3_120 } # LoopController: shipped! 所有 bug 状态 fixed → verified ``` ``` # INPUT 同上,但项目此时有 auth-be / auth-fe (OAuth 反馈后) # step 4 case 'login-then-profile' 第二步失败: step 0: POST /api/auth/login { email,password } → 200 { token: "jwt..." } ✓ step 1: GET /api/profile/me Authorization: Bearer {{step0.token}} → 实际 200 期望失败应当 401 ✗ 因为 auth-be 默认放行,没有真正校验 token # 注意:这种 e2e 测试反过来发现的是**实现没遵守 SDD** # 收集 http_trace.ndjson: {"ts":..., "method":"POST", "url":"/api/auth/login", "status":200, ...} {"ts":..., "method":"GET", "url":"/api/profile/me", "status":200, "expected":401} stderr_excerpt: "[auth-be] no JWT verification middleware mounted" # result.json { "run_id": "run-int-20260513-1230", "ok": false, "failures": [{ "case_id": "login-then-profile", "failed_step": 1, "got": { "status": 200 }, // 期望 401 "involves": ["auth-be", "profile-be"], "stderr_excerpt": "[auth-be] no JWT verification middleware mounted" }], "category_hint": "impl_bug" // 粗判,精修由 BugTriager } # OUTPUT { "ok": false, "cases_failed": 1, "failures": [ ... ], "category_hint": "impl_bug", "surviving_processes": [], // 关键:即使失败也 kill 干净 ... } # LoopController 接 ok=false → callAgent('bug-triager', { run_id }) ``` ``` # 端口冲突场景 # step 1: port_picker(1) # 尝试 1: 18421 → already in use (上一轮残留) # 尝试 2: 18422 → ok # 尝试 3: ... (不需要) # 但有时全部 3 次都冲突 (CI 高并发): port_picker(1) throws Error('no free port after 3 attempts') # 直接走 fail path result.json: { "run_id": "run-int-...", "ok": false, "crashed": true, "category_hint": "infra_flake", "crash_reason": "port_picker exhausted: 18421, 18422, 18423 all in use", "failures": [] } # LoopController: BugTriager 见 category=infra_flake → 直接重跑 Integrator (P-2) # 不动 Unit, 不算 L2 round ``` ## ⑦ 助手工具源码 ### 7.1 `port-picker.js` ``` // server/services/agent-tools/port-picker.js · ~40 行 const net = require('net'); const RESERVED = new Set([3010, 5173, 5432, 6379]); function isFree(port) { return new Promise((resolve) => { const srv = net.createServer(); srv.once('error', () => resolve(false)); srv.once('listening', () => srv.close(() => resolve(true))); srv.listen(port, '127.0.0.1'); }); } async function pickPorts(count, opts = {}) { const start = opts.start || 18000; const end = opts.end || 19000; const picked = []; let attempts = 0; while (picked.length < count && attempts < 100) { attempts++; const p = start + Math.floor(Math.random() * (end - start)); if (RESERVED.has(p)) continue; if (picked.includes(p)) continue; if (await isFree(p)) picked.push(p); } if (picked.length < count) throw new Error(`no free port after ${attempts} attempts`); return picked; } module.exports = { pickPorts }; ``` ### 7.2 `spawn-units.js` ``` // server/services/agent-tools/spawn-units.js · ~70 行 const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); async function spawnUnits({ pid, beUnits, ports, logPath }) { const handles = []; const logStream = fs.createWriteStream(logPath, { flags: 'a' }); for (const unitId of beUnits) { const port = ports[unitId]; const cwd = path.join(projectsDir(pid), 'units', unitId); const entry = path.join(cwd, 'code', 'server.js'); if (!fs.existsSync(entry)) throw new Error(`unit ${unitId} has no code/server.js`); const child = spawn('node', [entry], { cwd, env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', // 强制本地 UNIT_ID: unitId, }, detached: false, stdio: ['ignore', 'pipe', 'pipe'], }); const prefix = `[${unitId}:${port}] `; child.stdout.on('data', c => logStream.write(prefix + c)); child.stderr.on('data', c => logStream.write(prefix + c)); handles.push({ unitId, port, pid: child.pid, proc: child }); } return handles; } async function killAll(handles, graceMs = 5000) { for (const h of handles) { try { h.proc.kill('SIGTERM'); } catch {} } await new Promise(r => setTimeout(r, graceMs)); for (const h of handles) { try { if (!h.proc.killed) h.proc.kill('SIGKILL'); } catch {} } } module.exports = { spawnUnits, killAll }; ``` ### 7.3 `run-case.js` · 单 case 执行 + 占位符 ``` // server/services/agent-tools/run-case.js · ~80 行 async function runCase(c, ports, opts = {}) { const deadline = Date.now() + (opts.timeoutMs || 30_000); const stepResults = []; const httpTrace = []; for (let i = 0; i < c.steps.length; i++) { if (Date.now() > deadline) return { ok: false, failed_step: i, reason: 'timeout' }; const step = c.steps[i]; const body = parsePlaceholders(step.body || {}, stepResults); const headers = parsePlaceholders(step.headers || {}, stepResults); const [method, p] = step.call.split(' '); const port = ports[step.unit]; const url = `http://127.0.0.1:${port}` + p; const startedAt = Date.now(); const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: method === 'GET' ? undefined : JSON.stringify(body), }); const json = await r.json().catch(() => ({})); httpTrace.push({ ts: startedAt, step: i, unit: step.unit, method, url, status: r.status, body: json, duration_ms: Date.now()-startedAt, }); stepResults.push({ status: r.status, body: json }); // expect 校验 const ok = checkExpect(step.expect, { status: r.status, body: json }); if (!ok) { return { ok: false, failed_step: i, got: { status: r.status, body: json }, httpTrace }; } } return { ok: true, httpTrace }; } function parsePlaceholders(obj, prev) { const s = JSON.stringify(obj); const resolved = s.replace(/\{\{step(\d+)\.([\w.]+)\}\}/g, (_, n, p) => { const r = prev[parseInt(n, 10)]; if (!r) throw new Error(`placeholder step${n} has no result yet`); const parts = p.split('.'); let v = r.body; for (const k of parts) v = v?.[k]; return v != null ? String(v) : ''; }); return JSON.parse(resolved); } function checkExpect(expect, got) { if (expect.status && expect.status !== got.status) return false; // expect.json 支持简单字段匹配 + "$.length > 0" 类 JSONPath assertion // 详细实现略 return true; } module.exports = { runCase }; ``` ## ⑧ 自检 Checklist | # | 检查项 | 命令 | 判定 | | --- | --- | --- | --- | | 1 | 所有 spawn 的进程已 kill | `process_guard.surviving()` | 返回 [] | | 2 | result.json 通过 typebox | schema check | 合规 | | 3 | 每个 failure 含 involves + stderr_excerpt | jsonpath | true | | 4 | timeline 含 integration_done/failed | tail | 命中 | | 5 | todolist 全 done | read agent-state | true | | 6 | 无 0.0.0.0 / 公网 bind | grep env.json | 全 127.0.0.1 | | 7 | 未改任何 unit 文件 | scanSandbox(any unit/code/) | 无 mtime 变化 | | 8 | process.log 大小 < 10MB | fs.stat | true(超出自动 truncate) | ## ⑨ 失败模式 | 场景 | 处置 | | --- | --- | | 端口冲突 | port_picker 重试 3 次仍失败 → result.crashed=true · category_hint='infra_flake' | | be Unit 起不来 (health 30s 超时) | category_hint='infra_flake' · kill 已起的 · 整 run fail | | case 超时(30s) | 该 case 标 timeout,继续下一 case | | 整 run 超时(5min) | kill 全部 + crashed=true · infra_flake | | spec 引用未知 unit | 启动前校验失败,不起进程,报错回 Architect | | 占位符无法解析 | 该 case fail · reason='placeholder_unresolved' | | process_guard 检测残留 | SIGKILL 兜底,记 warning | | Integrator 自己崩 | LoopController retry Integrator 一次(不进 BugTriager) | ## ⑩ 握手摘要 | 对方 | 给 | 收 | | --- | --- | --- | | **LoopController** | IntegratorOutput | IntegratorInput | | **Architect** | (无) | integration/spec.json | | **所有 Coder** | 运行时进程 | code/** (只读启动) | | **BugTriager**(fail 时) | result.json + process.log + http-trace.ndjson | (无) | | **BugTracker** | bug_open(source='integration_test') | — | | **SSE** | integration.result | — | ## ⑪ 实战验证 ``` # 前置: 所有 unit passed,L2 gate 打开 # 1. 注册模板 # system/agents/devloop-integrator/prompt.md ← 本页 §2.2 # 2. 准备输入 echo '{"pid":"p-test001","mode":"run","call_id":"call-i-1","company_id":"c-test"}' > /tmp/int.json # 3. 跑 Integrator curl -X POST http://localhost:3010/api/devloop/_test/run-agent/integrator -d @/tmp/int.json # 4. 期望 1-3 分钟内 # data/companies/c-test/projects/p-test001/integration/runs/{runId}/ # ├── result.json (ok=true 或 false) # ├── process.log # ├── http-trace.ndjson # └── env.json (端口分配) # 5. 验证 cat data/companies/c-test/projects/p-test001/integration/runs/*/result.json | jq .ok # 期望 true (绿色场景) 或 false 含 failures (注入 mismatch 场景) # 6. 验证无残留进程 lsof -iTCP:18000-19000 | grep node # 期望: 空 (Integrator kill 干净) ``` **Integrator §⑪ 通过 = L2 上半段完成。**下半段(失败时归因)由 BugTriager 接力。