agents-integrator.html 39.2 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Integrator Agent · 完整 Skill 规范 · LemonBoss PRD</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="assets/shell.css" />
</head>
<body data-page="agents-integrator.html">
<div class="shell">
  <aside id="sidebar"></aside>
  <main class="page">
    <header class="page-head">
      <div class="crumb"><span class="pill">02 · DESIGN / Agents</span><span class="sep">/</span><span>Integrator Agent</span></div>
      <h1>Integrator Agent · 完整 Skill 规范</h1>
      <p class="deck">L2 的执行体。所有 Unit passed 后,<b>起多个真实进程</b>按 <code>integration/spec.json</code> 跑端到端测试,收 stderr / HTTP 调用链,干净退出。看到 Bug 只记录,不动代码。</p>
    </header>
    <article class="body">

      <section>
        <div class="agent-hero">
          <div class="avatar" style="background:var(--L2-tint);color:var(--L2)">I</div>
          <div>
            <h2 style="margin:0">Integrator Agent</h2>
            <p style="margin:6px 0">起真实进程 · 跑跨 Unit 用例 · 收集 stderr · 干净退出</p>
            <div class="meta">
              <div><b>Loop</b> <span class="tag L2">L2</span></div>
              <div><b>Model</b> Sonnet 4.6</div>
              <div><b>Modes</b> run · retry · resume</div>
              <div><b>沙箱</b> 全项目只读 + integration/runs/{runId}/ 写</div>
              <div><b>会话复用</b> 否(每次新会话,避免进程残留)</div>
            </div>
          </div>
        </div>
      </section>

      <section>
        <h2>① 功能需求</h2>
        <ol>
          <li><b>三种模式:</b><code>run</code>(L2 gate 首次开)/ <code>retry</code>(L1 重跑通过后再走 L2)/ <code>resume</code>。</li>
          <li><b>组装运行时:</b>按 <code>units.json</code> 拓扑顺序启动所有 be Unit 进程,每个分配独立端口(<code>port_picker</code>)。fe Unit 配置 API base 指向对应 be。</li>
          <li><b>跑 cases:</b>逐条执行 <code>integration/spec.json.cases</code>,每个用例是跨 Unit 工作流。</li>
          <li><b>占位符插值:</b>步骤 body / headers 中的 <code>{{step1.token}}</code> 形式占位符必须从前面步骤的 response.body 解析填充。</li>
          <li><b>结构化失败:</b>失败时记录 stderr 节选、HTTP 调用链、involves 列表,写 <code>result.json</code>。</li>
          <li><b>干净退出:</b>SIGTERM 所有起的进程,5s 后未死则 SIGKILL。trap SIGINT 兜底。</li>
          <li><b>不改代码:</b>看到 bug 只记录,<b>禁止</b>动 code/。归因由 BugTriager。</li>
          <li><b>不起公网端口:</b>所有进程 bind 127.0.0.1,避免外泄。</li>
          <li><b>总时长 5min 上限:</b>超时整轮 fail,标 infra_flake。</li>
        </ol>
      </section>

      <section>
        <h2>② Skill 设计</h2>

        <h3>2.1 输出沙箱</h3>
<pre class="tree">
data/companies/{cid}/projects/{pid}/integration/
└── runs/
    └── {runId}/                        <span class="com"># Integrator 写 ★</span>
        ├── result.json                 <span class="com"># 结构化结果 (M-8)</span>
        ├── process.log                 <span class="com"># 所有进程的 stdout/stderr 合并</span>
        ├── http-trace.ndjson           <span class="com"># 每个 HTTP 调用一行</span>
        └── env.json                    <span class="com"># 起的端口分配快照,便于诊断</span>

<span class="com"># Integrator 只读:</span>
units.json
integration/spec.json
units/*/code/                          <span class="com"># 起进程</span>
units/*/contract.json                  <span class="com"># 仅参考</span>
</pre>

        <h3>2.2 完整 System Prompt</h3>
        <p>落 <code>system/agents/devloop-integrator/prompt.md</code>。LoopController 头部注入 <code>## Memory</code> + <code>## Project Context</code>(units.json 摘要 + spec.json 摘要)。</p>

<pre class="code"><span class="c"># ROLE</span>
<span class="v">你是 LemonBoss DevLoop 的 Integrator Agent。本次任务:跑项目 {pid} 的集成测试。</span>
<span class="v">起所有 be Unit 真实进程,按 integration/spec.json 跑 e2e 用例,收 stderr,</span>
<span class="v">写 integration/runs/{runId}/result.json,然后干净 kill 所有进程。</span>

<span class="c"># SANDBOX</span>
<span class="v">写: 只能 integration/runs/{runId}/**</span>
<span class="v">读: units.json · integration/spec.json · units/*/code/ · units/*/contract.json</span>
<span class="v">禁止: 改 units/*/code/ · 改 sdd/ · 改 prd/ · 改 contract.json</span>

<span class="c"># MANDATORY WORKFLOW</span>

<span class="v">## step 0 · 读上下文</span>
<span class="v">  - 读 units.json 找所有 be 类 Unit (启动顺序按拓扑)</span>
<span class="v">  - 读 integration/spec.json 知道要跑哪些 case</span>
<span class="v">  - 读 units/*/contract.json 知道每个 Unit 暴露的 endpoint</span>

<span class="v">## step 1 · write_todolist (典型 8 项)</span>
<span class="v">  step-001 port_picker 给每个 be 分配端口 → 写 env.json</span>
<span class="v">  step-002 按拓扑 spawn be Unit 进程 (process_guard 看护)</span>
<span class="v">  step-003 健康检查:每个 be 等 /api/health 200 (最长 30s)</span>
<span class="v">  step-004 顺次跑 cases (run_case 工具)</span>
<span class="v">  step-005 收集 stderr / stdout / HTTP trace</span>
<span class="v">  step-006 SIGTERM 所有进程 → 5s 后 SIGKILL 兜底</span>
<span class="v">  step-007 写 result.json + process.log + http-trace.ndjson</span>
<span class="v">  step-008 timeline_append('integration_done'|'integration_failed')</span>

<span class="c"># RULES (硬约束)</span>

<span class="v">1. 沙箱:不许改任何 Unit 的 code/。看到 bug 只在 result.json 记录。</span>

<span class="v">2. 进程绑 127.0.0.1, 不允许 0.0.0.0 / 公网 IP。port_picker 工具内置约束。</span>

<span class="v">3. 单 case 总耗时 ≤ 30s;超时该 case 标 timeout,继续下一个 case。</span>
<span class="v">   全 run 总耗时 ≤ 5min;超出 → kill 所有 + result.crashed=true + category=infra_flake。</span>

<span class="v">4. 进程清理: 用 SIGTERM 给 5s grace,然后 SIGKILL。trap exit 兜底。</span>
<span class="v">   绝不留僵尸:函数返回前调 process_guard.killAll()。</span>

<span class="v">5. 任何 curl 带 JSON body → /tmp/*.json + -d @file (CLAUDE.md L91-99)。</span>
<span class="v">   或者直接用 fetch (推荐):</span>
<span class="v">     const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},</span>
<span class="v">                                   body: JSON.stringify(payload) });</span>

<span class="v">6. 占位符 {{stepN.field}} 必须从 response.body 通过 JSONPath 类似语法解析。</span>
<span class="v">   未解析的占位符 → case fail (而非传字面 "{{stepN.token}}")。</span>

<span class="v">7. spec 引用了不存在的 unit / endpoint → 启动前校验失败,不起进程,直接报错给 Architect。</span>

<span class="v">8. 端口冲突: port_picker 随机选 3 次,仍冲突 → 整轮 fail · category=infra_flake。</span>

<span class="v">9. 不调 call_sub_agent。Integrator 不分派。</span>

<span class="c"># SELF-CHECK</span>
<span class="v">1. 所有起的进程都 killed (process_guard.surviving() 应返 [])</span>
<span class="v">2. result.json 通过 typebox (M-8)</span>
<span class="v">3. 失败的 case 都含 involves + stderr_excerpt</span>
<span class="v">4. timeline 含 integration_started / integration_done|failed</span>
<span class="v">5. todolist 全 done</span>
<span class="v">6. 没写 integration/runs/{runId}/ 之外的文件</span></pre>

        <h3>2.3 工具集</h3>
        <table>
          <thead><tr><th>工具</th><th>签名</th><th>用途</th><th>访问边界</th></tr></thead>
          <tbody>
            <tr><td><code>Read</code></td><td>(path)</td><td>读 units.json / spec.json / contracts / code</td><td>项目只读</td></tr>
            <tr><td><code>Write</code></td><td>(path, content)</td><td>仅 integration/runs/{runId}/</td><td>白名单</td></tr>
            <tr><td><code>Bash</code></td><td>(cmd)</td><td>spawn node / curl / kill;jsonGuard + bind-check</td><td>cwd 锁项目目录, 5min 总超时</td></tr>
            <tr><td><code>port_picker</code> <span class="tag NEW">NEW</span></td><td>(count) → number[]</td><td>分配 N 个空闲 localhost 端口(避开 3010/5173 等系统占用)</td><td>—</td></tr>
            <tr><td><code>process_guard</code> <span class="tag REUSE">REUSE</span></td><td>spawn/wait/kill/killAll/surviving</td><td>复用 <code>server/src/engine/process-guard.js</code></td><td>—</td></tr>
            <tr><td><code>spawn_units</code> <span class="tag NEW">NEW</span></td><td>(units[], ports{}) → {pid: ProcessHandle[]}</td><td>批量启动 be Unit,每个独立端口 + env injection</td><td>—</td></tr>
            <tr><td><code>health_wait</code> <span class="tag NEW">NEW</span></td><td>(url, timeout=30s)</td><td>poll /api/health 直到 200 或超时</td><td>—</td></tr>
            <tr><td><code>run_case</code> <span class="tag NEW">NEW</span></td><td>(case, ports) → CaseResult</td><td>顺次执行 steps,{{stepN.field}} 解析,30s/case</td><td>—</td></tr>
            <tr><td><code>parse_placeholder</code> <span class="tag NEW">NEW</span></td><td>(template, prevSteps) → resolved</td><td>解析 <code>{{stepN.token}}</code> 形式</td><td>—</td></tr>
            <tr><td><code>collect_stderr</code> <span class="tag NEW">NEW</span></td><td>(handle, lines=20)</td><td>从子进程取最近 stderr 行</td><td>—</td></tr>
            <tr><td><code>write_todolist · update_todo_item · update_runtime_state · timeline_append · record_memory</code></td><td>同上</td><td>持久化 5 件套</td><td>agent-state/</td></tr>
          </tbody>
        </table>

        <h3>2.4 禁用</h3>
        <div class="callout warn">
          <ul style="margin:0">
            <li><b>不允许 Edit / Write code/</b> — 见到 bug 只记录</li>
            <li><b>不允许 bind 0.0.0.0 / 公网</b> — port_picker 强制 127.0.0.1</li>
            <li><b>不允许 call_sub_agent</b></li>
            <li><b>不允许 inline JSON Bash</b></li>
            <li><b>不允许留僵尸进程</b> — 函数返回前 surviving() 必须为空</li>
          </ul>
        </div>
      </section>

      <section>
        <h2>③ 持久化集成</h2>
        <p>同模式但<b>会话不复用</b>。每次 Integrator 调用是新 ACP session(旧的可能有进程残留风险)。todolist/timeline 同 Coder/Tester。Integrator memory 通常记 infra 经验:</p>
        <ul>
          <li>"node spawn 用 detached:false + cwd 锁定方便清理"</li>
          <li>"better-sqlite3 :memory: 跨进程不共享,集成测试每进程独立 DB"</li>
        </ul>
      </section>

      <section>
        <h2>④ I/O 契约</h2>
<pre class="code"><span class="k">const</span> IntegratorInput = Type.<span class="n">Object</span>(<span class="p">{</span>
  pid: Type.<span class="n">String</span>(), mode: Type.<span class="n">Union</span>(<span class="p">[</span>Type.<span class="n">Literal</span>(<span class="s">'run'</span>), Type.<span class="n">Literal</span>(<span class="s">'retry'</span>), Type.<span class="n">Literal</span>(<span class="s">'resume'</span>)<span class="p">]</span>),
  resume_token: Type.<span class="n">Optional</span>(Type.<span class="n">String</span>()),
  call_id: Type.<span class="n">String</span>(), company_id: Type.<span class="n">String</span>(),
<span class="p">}</span>);

<span class="k">const</span> IntegratorOutput = Type.<span class="n">Object</span>(<span class="p">{</span>
  ok:           Type.<span class="n">Boolean</span>(),
  mode:         Type.<span class="n">String</span>(),
  run_id:       Type.<span class="n">String</span>(),
  result_ref:   Type.<span class="n">String</span>(),

  cases_total:  Type.<span class="n">Number</span>(),
  cases_passed: Type.<span class="n">Number</span>(),
  cases_failed: Type.<span class="n">Number</span>(),
  cases_timeout: Type.<span class="n">Number</span>(),

  failures: Type.<span class="n">Array</span>(Type.<span class="n">Object</span>(<span class="p">{</span>
    case_id: Type.<span class="n">String</span>(),
    failed_step: Type.<span class="n">Number</span>(),
    got: Type.<span class="n">Any</span>(),
    involves: Type.<span class="n">Array</span>(Type.<span class="n">String</span>()),
    stderr_excerpt: Type.<span class="n">String</span>(),
  <span class="p">}</span>)),

  ports_used: Type.<span class="n">Record</span>(Type.<span class="n">String</span>(), Type.<span class="n">Number</span>()),
  surviving_processes: Type.<span class="n">Array</span>(Type.<span class="n">Any</span>()),    <span class="c">// 必为 []</span>
  category_hint: Type.<span class="n">Optional</span>(Type.<span class="n">String</span>()),       <span class="c">// 给 BugTriager 的粗判</span>
  crashed: Type.<span class="n">Boolean</span>(),
  duration_ms: Type.<span class="n">Number</span>(), tokens_used: Type.<span class="n">Number</span>(),
<span class="p">}</span>);</pre>
      </section>

      <section>
        <h2>⑤ 上下游传递</h2>
        <div class="io-grid">
          <div class="io-box">
            <h4>⬆ 上游</h4>
            <ul>
              <li><b>LoopController</b> · L2 闸门通过后调</li>
              <li><b>Architect</b> · 通过 integration/spec.json</li>
              <li><b>所有 Coder</b>(passed 后)· 通过 code/** 起进程</li>
            </ul>
          </div>
          <div class="io-box">
            <h4>⬇ 下游</h4>
            <ul>
              <li><b>LoopController</b> · 读 ok 决定 shipped / triage</li>
              <li><b>BugTriager</b>(fail 时)· 读 result.json + process.log + http-trace 做归因</li>
              <li><b>Dashboard</b> · SSE integration.result</li>
              <li><b>BugTracker</b>(fail 时)· bug_open(source=integration_test)</li>
            </ul>
          </div>
        </div>
      </section>

      <section>
        <h2>⑥ 完整执行样例</h2>

        <div class="tabs" data-tabs>
          <div class="tabbar" role="tablist">
            <button role="tab" aria-selected="true"  aria-controls="ia">Sample A · run 全绿</button>
            <button role="tab" aria-selected="false" aria-controls="ib">Sample B · run 失败 (contract mismatch)</button>
            <button role="tab" aria-selected="false" aria-controls="ic">Sample C · infra_flake (端口冲突)</button>
          </div>

          <div class="tabpanel" id="ia">
<pre class="code"><span class="c"># INPUT</span>
<span class="p">{</span> <span class="n">"pid"</span>:<span class="s">"p-test001"</span>, <span class="n">"mode"</span>:<span class="s">"run"</span>, <span class="n">"call_id"</span>:<span class="s">"call-i-1"</span>, ... <span class="p">}</span>

<span class="c"># 读 units.json:</span>
<span class="c">#   be units: todo-be</span>
<span class="c">#   fe units: todo-fe</span>

<span class="c"># 读 spec.json: 2 cases (见 Architect Sample A)</span>

<span class="c"># step 1 · port_picker(1) → { todo-be: 18421 } → 写 env.json</span>

<span class="c"># step 2 · spawn_units</span>
spawn_units(<span class="p">[</span><span class="s">'todo-be'</span><span class="p">]</span>, <span class="p">{</span> todo-be: <span class="v">18421</span> <span class="p">}</span>)
<span class="c">// 内部: spawn('node', ['units/todo-be/code/server.js'], {</span>
<span class="c">//                cwd, env: { PORT: 18421 }, detached: false })</span>

<span class="c"># step 3 · health_wait('http://127.0.0.1:18421/api/health', 30s)</span>
<span class="c">//   GET /api/health → 200 in 1.2s ✓</span>

<span class="c"># step 4 · 逐条跑 cases</span>

<span class="c"># case 1: create-then-list</span>
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" }] }   ✓

<span class="c"># case 2: ui-renders-from-api  (跨 todo-be + todo-fe)</span>
<span class="c"># fe Unit 没起进程,直接 mount Vue 组件 (用 @vue/test-utils)</span>
<span class="c"># API_BASE=http://127.0.0.1:18421 注入</span>
run_case(case2, ports):
  step 0: POST .../api/todos { title:"a" } → 201 ✓
  step 1: mount TodoListPage → DOM 含 li:contains('a') ✓

<span class="c"># step 6 · SIGTERM todo-be → 0.3s 死 ✓ surviving=[]</span>

<span class="c"># step 7 · 写 result.json</span>
<span class="p">{</span>
  <span class="n">"run_id"</span>: <span class="s">"run-int-20260513-1100"</span>,
  <span class="n">"ok"</span>: <span class="v">true</span>,
  <span class="n">"failures"</span>: <span class="p">[]</span>,
  <span class="n">"process_log"</span>: <span class="s">"integration/runs/run-int-../process.log"</span>,
  <span class="n">"duration_ms"</span>: <span class="v">4_312</span>
<span class="p">}</span>

<span class="c"># OUTPUT</span>
<span class="p">{</span>
  <span class="n">"ok"</span>: <span class="v">true</span>, <span class="n">"mode"</span>: <span class="s">"run"</span>,
  <span class="n">"run_id"</span>: <span class="s">"run-int-20260513-1100"</span>,
  <span class="n">"cases_total"</span>: <span class="v">2</span>, <span class="n">"cases_passed"</span>: <span class="v">2</span>, <span class="n">"cases_failed"</span>: <span class="v">0</span>, <span class="n">"cases_timeout"</span>: <span class="v">0</span>,
  <span class="n">"failures"</span>: <span class="p">[]</span>,
  <span class="n">"ports_used"</span>: <span class="p">{</span> <span class="s">"todo-be"</span>: <span class="v">18421</span> <span class="p">}</span>,
  <span class="n">"surviving_processes"</span>: <span class="p">[]</span>,
  <span class="n">"crashed"</span>: <span class="v">false</span>,
  <span class="n">"duration_ms"</span>: <span class="v">4_312</span>, <span class="n">"tokens_used"</span>: <span class="v">3_120</span>
<span class="p">}</span>

<span class="c"># LoopController: shipped! 所有 bug 状态 fixed → verified</span></pre>
          </div>

          <div class="tabpanel" id="ib" hidden>
<pre class="code"><span class="c"># INPUT 同上,但项目此时有 auth-be / auth-fe (OAuth 反馈后)</span>

<span class="c"># step 4 case 'login-then-profile' 第二步失败:</span>
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

<span class="c"># 注意:这种 e2e 测试反过来发现的是<b>实现没遵守 SDD</b></span>

<span class="c"># 收集</span>
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"

<span class="c"># result.json</span>
<span class="p">{</span>
  <span class="n">"run_id"</span>: <span class="s">"run-int-20260513-1230"</span>,
  <span class="n">"ok"</span>: <span class="v">false</span>,
  <span class="n">"failures"</span>: <span class="p">[{</span>
    <span class="n">"case_id"</span>: <span class="s">"login-then-profile"</span>,
    <span class="n">"failed_step"</span>: <span class="v">1</span>,
    <span class="n">"got"</span>: <span class="p">{</span> <span class="n">"status"</span>: <span class="v">200</span> <span class="p">}</span>,    <span class="c">// 期望 401</span>
    <span class="n">"involves"</span>: <span class="p">[</span><span class="s">"auth-be"</span>, <span class="s">"profile-be"</span><span class="p">]</span>,
    <span class="n">"stderr_excerpt"</span>: <span class="s">"[auth-be] no JWT verification middleware mounted"</span>
  <span class="p">}]</span>,
  <span class="n">"category_hint"</span>: <span class="s">"impl_bug"</span>   <span class="c">// 粗判,精修由 BugTriager</span>
<span class="p">}</span>

<span class="c"># OUTPUT</span>
<span class="p">{</span>
  <span class="n">"ok"</span>: <span class="v">false</span>, <span class="n">"cases_failed"</span>: <span class="v">1</span>,
  <span class="n">"failures"</span>: <span class="p">[</span> ... <span class="p">]</span>,
  <span class="n">"category_hint"</span>: <span class="s">"impl_bug"</span>,
  <span class="n">"surviving_processes"</span>: <span class="p">[]</span>,    <span class="c">// 关键:即使失败也 kill 干净</span>
  ...
<span class="p">}</span>

<span class="c"># LoopController 接 ok=false → callAgent('bug-triager', { run_id })</span></pre>
          </div>

          <div class="tabpanel" id="ic" hidden>
<pre class="code"><span class="c"># 端口冲突场景</span>

<span class="c"># step 1: port_picker(1)</span>
<span class="c">#   尝试 1: 18421 → already in use (上一轮残留)</span>
<span class="c">#   尝试 2: 18422 → ok</span>
<span class="c">#   尝试 3: ... (不需要)</span>

<span class="c"># 但有时全部 3 次都冲突 (CI 高并发):</span>
port_picker(<span class="v">1</span>) <span class="k">throws</span> Error(<span class="s">'no free port after 3 attempts'</span>)

<span class="c"># 直接走 fail path</span>
result.json:
<span class="p">{</span>
  <span class="n">"run_id"</span>: <span class="s">"run-int-..."</span>,
  <span class="n">"ok"</span>: <span class="v">false</span>, <span class="n">"crashed"</span>: <span class="v">true</span>,
  <span class="n">"category_hint"</span>: <span class="s">"infra_flake"</span>,
  <span class="n">"crash_reason"</span>: <span class="s">"port_picker exhausted: 18421, 18422, 18423 all in use"</span>,
  <span class="n">"failures"</span>: <span class="p">[]</span>
<span class="p">}</span>

<span class="c"># LoopController: BugTriager 见 category=infra_flake → 直接重跑 Integrator (P-2)</span>
<span class="c">#                  不动 Unit, 不算 L2 round</span></pre>
          </div>
        </div>
      </section>

      <section>
        <h2>⑦ 助手工具源码</h2>

        <h3>7.1 <code>port-picker.js</code></h3>
<pre class="code"><span class="c">// server/services/agent-tools/port-picker.js · ~40 行</span>
<span class="k">const</span> net = <span class="n">require</span>(<span class="s">'net'</span>);

<span class="k">const</span> RESERVED = <span class="k">new</span> <span class="n">Set</span>([<span class="v">3010</span>, <span class="v">5173</span>, <span class="v">5432</span>, <span class="v">6379</span>]);

<span class="k">function</span> <span class="n">isFree</span>(port) <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="n">Promise</span>((resolve) =&gt; <span class="p">{</span>
    <span class="k">const</span> srv = net.<span class="n">createServer</span>();
    srv.<span class="n">once</span>(<span class="s">'error'</span>, () =&gt; resolve(<span class="v">false</span>));
    srv.<span class="n">once</span>(<span class="s">'listening'</span>, () =&gt; srv.<span class="n">close</span>(() =&gt; resolve(<span class="v">true</span>)));
    srv.<span class="n">listen</span>(port, <span class="s">'127.0.0.1'</span>);
  <span class="p">}</span>);
<span class="p">}</span>

<span class="k">async function</span> <span class="n">pickPorts</span>(count, opts = <span class="p">{}</span>) <span class="p">{</span>
  <span class="k">const</span> start = opts.start || <span class="v">18000</span>;
  <span class="k">const</span> end   = opts.end   || <span class="v">19000</span>;
  <span class="k">const</span> picked = <span class="p">[]</span>;
  <span class="k">let</span> attempts = <span class="v">0</span>;
  <span class="k">while</span> (picked.<span class="n">length</span> &lt; count &amp;&amp; attempts &lt; <span class="v">100</span>) <span class="p">{</span>
    attempts++;
    <span class="k">const</span> p = start + <span class="n">Math.floor</span>(<span class="n">Math.random</span>() * (end - start));
    <span class="k">if</span> (RESERVED.<span class="n">has</span>(p)) <span class="k">continue</span>;
    <span class="k">if</span> (picked.<span class="n">includes</span>(p)) <span class="k">continue</span>;
    <span class="k">if</span> (<span class="k">await</span> <span class="n">isFree</span>(p)) picked.<span class="n">push</span>(p);
  <span class="p">}</span>
  <span class="k">if</span> (picked.<span class="n">length</span> &lt; count)
    <span class="k">throw new</span> <span class="n">Error</span>(<span class="s">`no free port after </span><span class="v">${attempts}</span><span class="s"> attempts`</span>);
  <span class="k">return</span> picked;
<span class="p">}</span>

module.<span class="n">exports</span> = <span class="p">{</span> pickPorts <span class="p">}</span>;</pre>

        <h3>7.2 <code>spawn-units.js</code></h3>
<pre class="code"><span class="c">// server/services/agent-tools/spawn-units.js · ~70 行</span>
<span class="k">const</span> <span class="p">{</span> spawn <span class="p">}</span> = <span class="n">require</span>(<span class="s">'child_process'</span>);
<span class="k">const</span> path = <span class="n">require</span>(<span class="s">'path'</span>);
<span class="k">const</span> fs   = <span class="n">require</span>(<span class="s">'fs'</span>);

<span class="k">async function</span> <span class="n">spawnUnits</span>(<span class="p">{</span> pid, beUnits, ports, logPath <span class="p">}</span>) <span class="p">{</span>
  <span class="k">const</span> handles = <span class="p">[]</span>;
  <span class="k">const</span> logStream = fs.<span class="n">createWriteStream</span>(logPath, <span class="p">{</span> flags: <span class="s">'a'</span> <span class="p">}</span>);

  <span class="k">for</span> (<span class="k">const</span> unitId <span class="k">of</span> beUnits) <span class="p">{</span>
    <span class="k">const</span> port = ports[unitId];
    <span class="k">const</span> cwd  = path.<span class="n">join</span>(<span class="n">projectsDir</span>(pid), <span class="s">'units'</span>, unitId);
    <span class="k">const</span> entry = path.<span class="n">join</span>(cwd, <span class="s">'code'</span>, <span class="s">'server.js'</span>);

    <span class="k">if</span> (!fs.<span class="n">existsSync</span>(entry))
      <span class="k">throw new</span> <span class="n">Error</span>(<span class="s">`unit </span><span class="v">${unitId}</span><span class="s"> has no code/server.js`</span>);

    <span class="k">const</span> child = <span class="n">spawn</span>(<span class="s">'node'</span>, [entry], <span class="p">{</span>
      cwd,
      env: <span class="p">{</span>
        ...process.<span class="n">env</span>,
        PORT: <span class="n">String</span>(port),
        HOST: <span class="s">'127.0.0.1'</span>,            <span class="c">// 强制本地</span>
        UNIT_ID: unitId,
      <span class="p">}</span>,
      detached: <span class="v">false</span>,
      stdio: <span class="p">[</span><span class="s">'ignore'</span>, <span class="s">'pipe'</span>, <span class="s">'pipe'</span><span class="p">]</span>,
    <span class="p">}</span>);

    <span class="k">const</span> prefix = <span class="s">`[</span><span class="v">${unitId}</span><span class="s">:</span><span class="v">${port}</span><span class="s">] `</span>;
    child.<span class="n">stdout</span>.<span class="n">on</span>(<span class="s">'data'</span>, c =&gt; logStream.<span class="n">write</span>(prefix + c));
    child.<span class="n">stderr</span>.<span class="n">on</span>(<span class="s">'data'</span>, c =&gt; logStream.<span class="n">write</span>(prefix + c));

    handles.<span class="n">push</span>(<span class="p">{</span> unitId, port, pid: child.<span class="n">pid</span>, proc: child <span class="p">}</span>);
  <span class="p">}</span>

  <span class="k">return</span> handles;
<span class="p">}</span>

<span class="k">async function</span> <span class="n">killAll</span>(handles, graceMs = <span class="v">5000</span>) <span class="p">{</span>
  <span class="k">for</span> (<span class="k">const</span> h <span class="k">of</span> handles) <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span> h.proc.<span class="n">kill</span>(<span class="s">'SIGTERM'</span>); <span class="p">}</span> <span class="k">catch</span> {}
  <span class="p">}</span>
  <span class="k">await</span> <span class="k">new</span> <span class="n">Promise</span>(r =&gt; <span class="n">setTimeout</span>(r, graceMs));
  <span class="k">for</span> (<span class="k">const</span> h <span class="k">of</span> handles) <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span> <span class="k">if</span> (!h.proc.<span class="n">killed</span>) h.proc.<span class="n">kill</span>(<span class="s">'SIGKILL'</span>); <span class="p">}</span> <span class="k">catch</span> {}
  <span class="p">}</span>
<span class="p">}</span>

module.<span class="n">exports</span> = <span class="p">{</span> spawnUnits, killAll <span class="p">}</span>;</pre>

        <h3>7.3 <code>run-case.js</code> · 单 case 执行 + 占位符</h3>
<pre class="code"><span class="c">// server/services/agent-tools/run-case.js · ~80 行</span>
<span class="k">async function</span> <span class="n">runCase</span>(c, ports, opts = <span class="p">{}</span>) <span class="p">{</span>
  <span class="k">const</span> deadline = <span class="n">Date.now</span>() + (opts.timeoutMs || <span class="v">30_000</span>);
  <span class="k">const</span> stepResults = <span class="p">[]</span>;
  <span class="k">const</span> httpTrace = <span class="p">[]</span>;

  <span class="k">for</span> (<span class="k">let</span> i = <span class="v">0</span>; i &lt; c.steps.<span class="n">length</span>; i++) <span class="p">{</span>
    <span class="k">if</span> (<span class="n">Date.now</span>() &gt; deadline)
      <span class="k">return</span> <span class="p">{</span> ok: <span class="v">false</span>, failed_step: i, reason: <span class="s">'timeout'</span> <span class="p">}</span>;

    <span class="k">const</span> step = c.steps[i];
    <span class="k">const</span> body    = <span class="n">parsePlaceholders</span>(step.body || {}, stepResults);
    <span class="k">const</span> headers = <span class="n">parsePlaceholders</span>(step.headers || {}, stepResults);

    <span class="k">const</span> [method, p] = step.<span class="n">call</span>.<span class="n">split</span>(<span class="s">' '</span>);
    <span class="k">const</span> port = ports[step.unit];
    <span class="k">const</span> url = <span class="s">`http://127.0.0.1:</span><span class="v">${port}</span><span class="s">`</span> + p;

    <span class="k">const</span> startedAt = <span class="n">Date.now</span>();
    <span class="k">const</span> r = <span class="k">await</span> <span class="n">fetch</span>(url, <span class="p">{</span>
      method,
      headers: <span class="p">{</span> <span class="s">'Content-Type'</span>: <span class="s">'application/json'</span>, ...headers <span class="p">}</span>,
      body: method === <span class="s">'GET'</span> ? <span class="v">undefined</span> : <span class="n">JSON.stringify</span>(body),
    <span class="p">}</span>);
    <span class="k">const</span> json = <span class="k">await</span> r.<span class="n">json</span>().<span class="n">catch</span>(() =&gt; (<span class="p">{}</span>));

    httpTrace.<span class="n">push</span>(<span class="p">{</span>
      ts: startedAt, step: i, unit: step.unit, method, url,
      status: r.<span class="n">status</span>, body: json, duration_ms: <span class="n">Date.now</span>()-startedAt,
    <span class="p">}</span>);
    stepResults.<span class="n">push</span>(<span class="p">{</span> status: r.<span class="n">status</span>, body: json <span class="p">}</span>);

    <span class="c">// expect 校验</span>
    <span class="k">const</span> ok = <span class="n">checkExpect</span>(step.expect, <span class="p">{</span> status: r.<span class="n">status</span>, body: json <span class="p">}</span>);
    <span class="k">if</span> (!ok) <span class="p">{</span>
      <span class="k">return</span> <span class="p">{</span> ok: <span class="v">false</span>, failed_step: i, got: <span class="p">{</span> status: r.<span class="n">status</span>, body: json <span class="p">}</span>, httpTrace <span class="p">}</span>;
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="p">{</span> ok: <span class="v">true</span>, httpTrace <span class="p">}</span>;
<span class="p">}</span>

<span class="k">function</span> <span class="n">parsePlaceholders</span>(obj, prev) <span class="p">{</span>
  <span class="k">const</span> s = <span class="n">JSON.stringify</span>(obj);
  <span class="k">const</span> resolved = s.<span class="n">replace</span>(<span class="s">/\{\{step(\d+)\.([\w.]+)\}\}/g</span>, (_, n, p) =&gt; <span class="p">{</span>
    <span class="k">const</span> r = prev[<span class="n">parseInt</span>(n, <span class="v">10</span>)];
    <span class="k">if</span> (!r) <span class="k">throw new</span> <span class="n">Error</span>(<span class="s">`placeholder step</span><span class="v">${n}</span><span class="s"> has no result yet`</span>);
    <span class="k">const</span> parts = p.<span class="n">split</span>(<span class="s">'.'</span>);
    <span class="k">let</span> v = r.body;
    <span class="k">for</span> (<span class="k">const</span> k <span class="k">of</span> parts) v = v?.[k];
    <span class="k">return</span> v != <span class="v">null</span> ? <span class="n">String</span>(v) : <span class="s">''</span>;
  <span class="p">}</span>);
  <span class="k">return</span> <span class="n">JSON.parse</span>(resolved);
<span class="p">}</span>

<span class="k">function</span> <span class="n">checkExpect</span>(expect, got) <span class="p">{</span>
  <span class="k">if</span> (expect.status &amp;&amp; expect.status !== got.status) <span class="k">return</span> <span class="v">false</span>;
  <span class="c">// expect.json 支持简单字段匹配 + "$.length &gt; 0" 类 JSONPath assertion</span>
  <span class="c">// 详细实现略</span>
  <span class="k">return</span> <span class="v">true</span>;
<span class="p">}</span>

module.<span class="n">exports</span> = <span class="p">{</span> runCase <span class="p">}</span>;</pre>
      </section>

      <section>
        <h2>⑧ 自检 Checklist</h2>
        <table>
          <thead><tr><th>#</th><th>检查项</th><th>命令</th><th>判定</th></tr></thead>
          <tbody>
            <tr><td>1</td><td>所有 spawn 的进程已 kill</td><td><code>process_guard.surviving()</code></td><td>返回 []</td></tr>
            <tr><td>2</td><td>result.json 通过 typebox</td><td>schema check</td><td>合规</td></tr>
            <tr><td>3</td><td>每个 failure 含 involves + stderr_excerpt</td><td>jsonpath</td><td>true</td></tr>
            <tr><td>4</td><td>timeline 含 integration_done/failed</td><td>tail</td><td>命中</td></tr>
            <tr><td>5</td><td>todolist 全 done</td><td>read agent-state</td><td>true</td></tr>
            <tr><td>6</td><td>无 0.0.0.0 / 公网 bind</td><td>grep env.json</td><td>全 127.0.0.1</td></tr>
            <tr><td>7</td><td>未改任何 unit 文件</td><td>scanSandbox(any unit/code/)</td><td>无 mtime 变化</td></tr>
            <tr><td>8</td><td>process.log 大小 &lt; 10MB</td><td>fs.stat</td><td>true(超出自动 truncate)</td></tr>
          </tbody>
        </table>
      </section>

      <section>
        <h2>⑨ 失败模式</h2>
        <table class="dense">
          <thead><tr><th>场景</th><th>处置</th></tr></thead>
          <tbody>
            <tr><td>端口冲突</td><td>port_picker 重试 3 次仍失败 → result.crashed=true · category_hint='infra_flake'</td></tr>
            <tr><td>be Unit 起不来 (health 30s 超时)</td><td>category_hint='infra_flake' · kill 已起的 · 整 run fail</td></tr>
            <tr><td>case 超时(30s)</td><td>该 case 标 timeout,继续下一 case</td></tr>
            <tr><td>整 run 超时(5min)</td><td>kill 全部 + crashed=true · infra_flake</td></tr>
            <tr><td>spec 引用未知 unit</td><td>启动前校验失败,不起进程,报错回 Architect</td></tr>
            <tr><td>占位符无法解析</td><td>该 case fail · reason='placeholder_unresolved'</td></tr>
            <tr><td>process_guard 检测残留</td><td>SIGKILL 兜底,记 warning</td></tr>
            <tr><td>Integrator 自己崩</td><td>LoopController retry Integrator 一次(不进 BugTriager)</td></tr>
          </tbody>
        </table>
      </section>

      <section>
        <h2>⑩ 握手摘要</h2>
        <table class="dense">
          <thead><tr><th>对方</th><th>给</th><th>收</th></tr></thead>
          <tbody>
            <tr><td><b>LoopController</b></td><td>IntegratorOutput</td><td>IntegratorInput</td></tr>
            <tr><td><b>Architect</b></td><td>(无)</td><td>integration/spec.json</td></tr>
            <tr><td><b>所有 Coder</b></td><td>运行时进程</td><td>code/** (只读启动)</td></tr>
            <tr><td><b>BugTriager</b>(fail 时)</td><td>result.json + process.log + http-trace.ndjson</td><td>(无)</td></tr>
            <tr><td><b>BugTracker</b></td><td>bug_open(source='integration_test')</td><td>—</td></tr>
            <tr><td><b>SSE</b></td><td>integration.result</td><td>—</td></tr>
          </tbody>
        </table>
      </section>

      <section>
        <h2>⑪ 实战验证</h2>
<pre class="code"><span class="c"># 前置: 所有 unit passed,L2 gate 打开</span>

<span class="c"># 1. 注册模板</span>
<span class="c">#    system/agents/devloop-integrator/prompt.md ← 本页 §2.2</span>

<span class="c"># 2. 准备输入</span>
echo <span class="s">'{"pid":"p-test001","mode":"run","call_id":"call-i-1","company_id":"c-test"}'</span> &gt; /tmp/int.json

<span class="c"># 3. 跑 Integrator</span>
curl -X POST http://localhost:3010/api/devloop/_test/run-agent/integrator -d @/tmp/int.json

<span class="c"># 4. 期望 1-3 分钟内</span>
<span class="c">#    data/companies/c-test/projects/p-test001/integration/runs/{runId}/</span>
<span class="c">#    ├── result.json (ok=true 或 false)</span>
<span class="c">#    ├── process.log</span>
<span class="c">#    ├── http-trace.ndjson</span>
<span class="c">#    └── env.json (端口分配)</span>

<span class="c"># 5. 验证</span>
cat data/companies/c-test/projects/p-test001/integration/runs/*/result.json | jq .ok
<span class="c"># 期望 true (绿色场景) 或 false 含 failures (注入 mismatch 场景)</span>

<span class="c"># 6. 验证无残留进程</span>
lsof -iTCP:18000-19000 | grep node
<span class="c"># 期望: 空 (Integrator kill 干净)</span></pre>
        <div class="callout ok"><b>Integrator §⑪ 通过 = L2 上半段完成。</b>下半段(失败时归因)由 BugTriager 接力。</div>
      </section>

    </article>
    <nav class="prev-next"></nav>
    <footer class="site">
      <div><b>Section</b>02 · Design / Agents</div>
      <div><b>Page</b>agents-integrator.html</div>
      <div><b>Spec Rev</b>v2 · 2026-05-13</div>
    </footer>
  </main>
</div>
<script src="assets/shell.js"></script>
</body>
</html>