Commit 151acb2247295a8023745f9eb586aef3dd4275b5

Authored by yibo
1 parent 3c14a4dd

feat(devloop): P3 L1+L2 联动 — runProject + 4 triage branches + SSE + bugs

Implements the §P3 acceptance gate from plan-migration.md: a single
POST /api/devloop/projects/:pid/run drives the full project from L1
through L2 with 4 distinct BugTriager branches, observability via SSE
stream + bugs.ndjson + changelog.ndjson, and a tiny static status page.

Mock protocol — fallback dir:
- packages/acp-core/protocol/ClaudeCliProtocol.js
  * New options.mockFallbackDir: when scenario dir lacks a per-agent
    fixture, fall back to the baseline fixtures/devloop-mock/.
    Enables scenario dirs to override only the agents that differ
    (Integrator + Triager + sometimes Architect).

Scenario fixtures — 4 L2 branches:
- fixtures/devloop-mock-scenarios/contract_mismatch/
  * Architect: sequence[init, patch] — patch bumps sdd_rev → 2,
    units.json l2_rounds → 1, marks todo-be back to pending.
  * Integrator: sequence[fail (500 vs 201), pass].
  * Triager: contract_mismatch confidence 0.85 with proposed_sdd_patch.
- fixtures/devloop-mock-scenarios/impl_bug/
  * Integrator: sequence[fail (ReferenceError), pass].
  * Triager: impl_bug confidence 0.78 affected=[todo-be], proposed=null.
- fixtures/devloop-mock-scenarios/spec_ambiguous/
  * Triager: confidence 0.4 → LoopController emits human_intervention.
- fixtures/devloop-mock-scenarios/infra_flake/
  * Integrator: sequence[crashed (EADDRINUSE), pass].
  * Triager: infra_flake confidence 0.95 affected=[].
- Each scenario also includes a 1-unit (todo-be) Architect.init fixture
  so baseline Coder/Tester (todo-be) can drive L1.

LoopController:
- server/src/core/devloop/loop-controller.js — adds:
  * runProject(pid, opts) — wave-scheduled L1 via _groupUnitsByWave,
    runIntegrator (devloop-integrator), runTriager (devloop-triager),
    then branch dispatch:
      - contract_mismatch+conf≥0.5 → Architect(patch) writes new
        units.json (already pending, l2_rounds++); emits sdd.updated +
        changelog sdd_rev entry; loop back to L1.
      - impl_bug+conf≥0.5 → unitRegistry.reset(affected); loop, no
        budget burn.
      - spec_ambiguous OR conf<0.5 → emit human_intervention_needed
        SSE, exit l2_blocked.
      - infra_flake → loop directly (no reset, no budget burn).
  * runDocsPhase + runUnit + callAgent all thread opts.mockFixtureDir
    through to the protocol.
  * Auto-attaches sseBroker to unit-registry and bug-tracker on module
    load — every FSM transition + bug op fires an SSE event.
  * bug_open at integration failure, bug_transition → fixed when
    subsequent L2 round passes.

Routes:
- server/routes/devloop.js:
  * POST /projects/:pid/run now defaults to full runProject; ?only=<id>
    still drills into single-unit runUnit (P2 compatibility).
  * GET  /projects/:pid/stream — SSE with Last-Event-ID replay.
  * GET  /projects/:pid/bugs?status=&unit_id=&severity= — bug list.
- server/index.js + agent-server.js mount /public for static assets.

Static status page:
- server/public/devloop-status.html (~100 lines) — pure HTML, polls
  GET /state every 2s, renders unit DAG with status pills + timeline
  tail. URL ?pid= or input box.

Acceptance:
- scripts/phase3-verify.js — 9 steps across 4 scenarios + observability:
  ① contract_mismatch ships at sdd_rev=2 l2_rounds=1, sdd.updated SSE,
    changelog sdd_rev entry, bug open → fixed.
  ② impl_bug ships, sdd_rev=1, l2_rounds=0 (no budget burn).
  ③ spec_ambiguous → l2_blocked + human_intervention_needed SSE.
  ④ infra_flake ships, rerun_integrator action, l2_rounds=0.
  ⑤ Observability: timeline/bugs/changelog/stream.log present.
  ⑥ SSE replay from Last-Event-ID returns ≥2 historical events.

Tests (+13 cases):
- server/tests/devloop/run-project.test.js (11) — 4 scenarios end to
  end + SSE event capture.
- server/tests/devloop/routes.test.js (+2) — full-project mode + bugs
  404. Updated P2 ?only stub test → full-project 404 on unknown pid.

Results:
- phase0-verify: 8/8
- phase0-test-call: 7/7
- phase1-verify: 7/7
- phase2-verify: 6/6
- phase3-verify: 9/9
- vitest devloop+acp-core: 194/194 (was 181 → +13)

Specs:
- docs/prd/newfunction/_spec/plan-migration.md · P3 验收
- docs/prd/newfunction/_spec/loops-l2.md (failure attribution + budget)
- docs/prd/newfunction/_spec/agents-integrator.md
- docs/prd/newfunction/_spec/agents-triager.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).",
  3 + "sequence": [
  4 + {
  5 + "_variant": "init",
  6 + "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"],
  7 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  8 + "files": [
  9 + {
  10 + "path": "sdd/index.html",
  11 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  12 + },
  13 + {
  14 + "path": "sdd/mvcp/control.html",
  15 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev1</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create (ambiguous on errors)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v1</div></section></article></body></html>"
  16 + },
  17 + {
  18 + "path": "units.json",
  19 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"1\",\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  20 + },
  21 + {
  22 + "path": "integration/spec.json",
  23 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  24 + },
  25 + {
  26 + "path": "units/todo-be/spec.json",
  27 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}"
  28 + },
  29 + {
  30 + "path": "history/sdd-rev-001.html",
  31 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap1</h3><p>rev1</p></section></article></body></html>"
  32 + }
  33 + ]
  34 + },
  35 + {
  36 + "_variant": "patch",
  37 + "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"],
  38 + "result": "mock-ok devloop-architect[patch]: SDD rev 2",
  39 + "files": [
  40 + {
  41 + "path": "sdd/mvcp/control.html",
  42 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev 2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos (clarified)</h3><p>201 on create; 400 on missing text</p><table><tr><td>text</td><td>required</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v2</div></section></article></body></html>"
  43 + },
  44 + {
  45 + "path": "sdd/index.html",
  46 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD rev2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\"><h3>SDD</h3><p>rev 2</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  47 + },
  48 + {
  49 + "path": "units.json",
  50 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 2,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"2\",\n \"updated_at\": \"2026-05-13T10:05:00.000Z\",\n \"l2_rounds\": 1,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:00000000000000aa\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  51 + },
  52 + {
  53 + "path": "history/sdd-rev-002.html",
  54 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"1\"><h3>snap2</h3><p>patch round 1</p></section></article></body></html>"
  55 + }
  56 + ]
  57 + }
  58 + ]
  59 +}
... ...
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-fail",
  6 + "chunks": [
  7 + "## step 0 · 读 units.json + integration/spec.json\n",
  8 + "## step 1-4 · 起 be 进程,跑 case-add-list\n",
  9 + "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n"
  10 + ],
  11 + "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch",
  12 + "files": [
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/result.json",
  15 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\", \"todo-fe\"],\n \"got\": { \"status\": 500, \"body\": {} },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"TypeError: cannot read property text\"\n }],\n \"duration_ms\": 1234\n}\n"
  16 + },
  17 + {
  18 + "path": "integration/runs/run-mock-l2-1/process.log",
  19 + "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n"
  20 + },
  21 + {
  22 + "path": "integration/runs/run-mock-l2-1/http-trace.ndjson",
  23 + "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n"
  24 + }
  25 + ]
  26 + },
  27 + {
  28 + "_variant": "round2-pass",
  29 + "chunks": [
  30 + "## L2 round 2 (after SDD patched + L1 reran)\n",
  31 + "case-add-list 通过 ✓\n"
  32 + ],
  33 + "result": "mock-ok devloop-integrator[round2] PASS: integration green",
  34 + "files": [
  35 + {
  36 + "path": "integration/runs/run-mock-l2-2/result.json",
  37 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 980\n}\n"
  38 + }
  39 + ]
  40 + }
  41 + ]
  42 +}
... ...
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).",
  3 + "chunks": [
  4 + "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n",
  5 + "## step 1 · category_classifier → contract_mismatch\n",
  6 + "## step 2 · 起草 proposed_sdd_patch\n",
  7 + "## step 3 · 写 triage.json + evidence 自检\n"
  8 + ],
  9 + "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]",
  10 + "files": [
  11 + {
  12 + "path": "integration/runs/run-mock-l2-1/triage.json",
  13 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be 返 500 但 SDD 规定参数错应返 400; contract 描述与实现不齐\",\n \"category\": \"contract_mismatch\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": {\n \"target_section\": \"#unit-todo-be\",\n \"op\": \"update\",\n \"text\": \"明确: POST /todos 缺 text 字段必须返 400 not 500\"\n },\n \"confidence\": 0.85,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/result.json: status: 500\",\n \"integration/runs/run-mock-l2-1/process.log: TypeError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 320\n}\n"
  14 + }
  15 + ]
  16 +}
... ...
fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  5 + "files": [
  6 + {
  7 + "path": "sdd/index.html",
  8 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  9 + },
  10 + {
  11 + "path": "sdd/mvcp/control.html",
  12 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create, 400 on missing text (explicit)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">api</div></section></article></body></html>"
  13 + },
  14 + {
  15 + "path": "units.json",
  16 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  17 + },
  18 + {
  19 + "path": "integration/spec.json",
  20 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  21 + },
  22 + {
  23 + "path": "units/todo-be/spec.json",
  24 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
  25 + },
  26 + {
  27 + "path": "history/sdd-rev-001.html",
  28 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap</h3><p>rev1</p></section></article></body></html>"
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-fail",
  6 + "chunks": ["## case fail: todo-be assertion mismatch\n"],
  7 + "result": "mock-ok devloop-integrator[round1]: impl_bug failure",
  8 + "files": [
  9 + {
  10 + "path": "integration/runs/run-mock-l2-1/result.json",
  11 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 500 },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"ReferenceError: passwrd is not defined\"\n }],\n \"duration_ms\": 1100\n}\n"
  12 + },
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/process.log",
  15 + "content": "[todo-be] ReferenceError at line 8\n"
  16 + }
  17 + ]
  18 + },
  19 + {
  20 + "_variant": "round2-pass",
  21 + "chunks": ["## pass after coder reset\n"],
  22 + "result": "mock-ok devloop-integrator[round2]: pass",
  23 + "files": [
  24 + {
  25 + "path": "integration/runs/run-mock-l2-2/result.json",
  26 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 950\n}\n"
  27 + }
  28 + ]
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。",
  3 + "chunks": [
  4 + "## category_classifier → impl_bug (SDD 明确,code 实现错)\n"
  5 + ],
  6 + "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]",
  7 + "files": [
  8 + {
  9 + "path": "integration/runs/run-mock-l2-1/triage.json",
  10 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be code/server.js 抛 ReferenceError; SDD 明确写 POST /todos 入参为 text 字段,代码没遵守\",\n \"category\": \"impl_bug\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.78,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: ReferenceError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 290\n}\n"
  11 + }
  12 + ]
  13 +}
... ...
fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  5 + "files": [
  6 + {
  7 + "path": "sdd/index.html",
  8 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  9 + },
  10 + {
  11 + "path": "sdd/mvcp/control.html",
  12 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create, 400 on missing text (explicit)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">api</div></section></article></body></html>"
  13 + },
  14 + {
  15 + "path": "units.json",
  16 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  17 + },
  18 + {
  19 + "path": "integration/spec.json",
  20 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  21 + },
  22 + {
  23 + "path": "units/todo-be/spec.json",
  24 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
  25 + },
  26 + {
  27 + "path": "history/sdd-rev-001.html",
  28 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap</h3><p>rev1</p></section></article></body></html>"
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-crash",
  6 + "chunks": ["## EADDRINUSE on :18001\n"],
  7 + "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE",
  8 + "files": [
  9 + {
  10 + "path": "integration/runs/run-mock-l2-1/result.json",
  11 + "content": "{\n \"ok\": false,\n \"crashed\": true,\n \"run_id\": \"run-mock-l2-1\",\n \"crash_reason\": \"EADDRINUSE on :18001\",\n \"summary\": { \"total\": 0, \"passed\": 0, \"failed\": 0 },\n \"failures\": [],\n \"stderr_excerpt\": \"Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\"\n}\n"
  12 + },
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/process.log",
  15 + "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n"
  16 + }
  17 + ]
  18 + },
  19 + {
  20 + "_variant": "round2-pass",
  21 + "chunks": ["## retry: new port, pass\n"],
  22 + "result": "mock-ok devloop-integrator[round2]: pass",
  23 + "files": [
  24 + {
  25 + "path": "integration/runs/run-mock-l2-2/result.json",
  26 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 920\n}\n"
  27 + }
  28 + ]
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.",
  3 + "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"],
  4 + "result": "mock-ok devloop-triager: infra_flake confidence=0.95",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/triage.json",
  8 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"端口 18001 已被占用 (EADDRINUSE),进程未起;与 code/SDD 无关\",\n \"category\": \"infra_flake\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.95,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: EADDRINUSE\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 180\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  5 + "files": [
  6 + {
  7 + "path": "sdd/index.html",
  8 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  9 + },
  10 + {
  11 + "path": "sdd/mvcp/control.html",
  12 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create, 400 on missing text (explicit)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">api</div></section></article></body></html>"
  13 + },
  14 + {
  15 + "path": "units.json",
  16 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  17 + },
  18 + {
  19 + "path": "integration/spec.json",
  20 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  21 + },
  22 + {
  23 + "path": "units/todo-be/spec.json",
  24 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
  25 + },
  26 + {
  27 + "path": "history/sdd-rev-001.html",
  28 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap</h3><p>rev1</p></section></article></body></html>"
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.",
  3 + "chunks": ["## case fail (spec_ambiguous)\n"],
  4 + "result": "mock-ok devloop-integrator: failure due to ambiguous SDD",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/result.json",
  8 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 200, \"body\": { \"id\": 1 } },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"behavior undefined in SDD\"\n }],\n \"duration_ms\": 1000\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.",
  3 + "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"],
  4 + "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/triage.json",
  8 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"SDD 没明确规定 POST /todos 应返 200 还是 201; 两种解读都说得通\",\n \"category\": \"spec_ambiguous\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.4,\n \"evidence\": [],\n \"will_auto_apply\": false,\n \"evidence_verified\": true,\n \"duration_ms\": 250\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
packages/acp-core/protocol/ClaudeCliProtocol.js
... ... @@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter {
49 49 this._mockMode = MOCK_MODE || options.mock === true;
50 50 this._mockFixtureDir = options.mockFixtureDir
51 51 || path.resolve(__dirname, '../../../fixtures/devloop-mock');
  52 + // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录
  53 + this._mockFallbackDir = options.mockFallbackDir
  54 + || path.resolve(__dirname, '../../../fixtures/devloop-mock');
52 55 }
53 56  
54 57 /**
... ... @@ -536,21 +539,25 @@ class ClaudeCliProtocol extends EventEmitter {
536 539 async _sendMockPrompt(text) {
537 540 const agentName = this.agentInfo?.name || this.options.agentName || 'default';
538 541 const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`);
539   - const defaultPath = path.join(this._mockFixtureDir, 'default.json');
  542 + const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir
  543 + ? path.join(this._mockFallbackDir, `${agentName}.json`)
  544 + : null;
  545 + const defaultPath = path.join(this._mockFallbackDir, 'default.json');
540 546  
541 547 let raw;
542   - if (fs.existsSync(fixturePath)) {
  548 + const tryPaths = [fixturePath];
  549 + if (fallbackPath) tryPaths.push(fallbackPath);
  550 + tryPaths.push(defaultPath);
  551 +
  552 + for (const p of tryPaths) {
  553 + if (!fs.existsSync(p)) continue;
543 554 try {
544   - raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
  555 + raw = JSON.parse(fs.readFileSync(p, 'utf8'));
  556 + break;
545 557 } catch (e) {
546   - console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`);
  558 + console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`);
547 559 }
548 560 }
549   - if (!raw && fs.existsSync(defaultPath)) {
550   - try {
551   - raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
552   - } catch { /* ignore */ }
553   - }
554 561 if (!raw) {
555 562 // echo fallback: 把 prompt 头 80 字符回显
556 563 raw = {
... ...
scripts/phase3-verify.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/phase3-verify.js
  4 + *
  5 + * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支):
  6 + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条
  7 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit
  8 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
  9 + * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset
  10 + * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay
  11 + *
  12 + * 用法: node scripts/phase3-verify.js
  13 + */
  14 +
  15 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  16 +
  17 +const fs = require('fs');
  18 +const path = require('path');
  19 +
  20 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  21 +require('module').globalPaths.push(serverNodeModules);
  22 +const express = (() => {
  23 + try { return require('express'); }
  24 + catch {
  25 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  26 + }
  27 +})();
  28 +
  29 +const pathResolver = require('../server/services/devloop/path-resolver');
  30 +const sseBroker = require('../server/services/devloop/sse-broker');
  31 +const bugTracker = require('../server/services/devloop/bug-tracker');
  32 +const lc = require('../server/src/core/devloop/loop-controller');
  33 +
  34 +const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios');
  35 +
  36 +const results = [];
  37 +function step(name, fn) {
  38 + return (async () => {
  39 + try {
  40 + const r = await fn();
  41 + results.push({ name, ok: true, detail: r?.detail || '' });
  42 + return r;
  43 + } catch (e) {
  44 + results.push({ name, ok: false, reason: e.message });
  45 + throw e;
  46 + }
  47 + })();
  48 +}
  49 +
  50 +function makeFakeRes(events) {
  51 + return {
  52 + setHeader() {}, flushHeaders() {}, end() {},
  53 + on() {},
  54 + write(line) {
  55 + const m = line.match(/^event: (.+)$/m);
  56 + if (m) events.push(m[1]);
  57 + },
  58 + };
  59 +}
  60 +
  61 +async function runScenario(scenario) {
  62 + pathResolver._resetForTesting();
  63 + sseBroker._resetForTesting();
  64 + const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36);
  65 + const dir = path.join(SCENARIO_BASE, scenario);
  66 + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario });
  67 + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
  68 +
  69 + const events = [];
  70 + sseBroker.subscribe(proj.pid, makeFakeRes(events), {});
  71 +
  72 + const result = await lc.runProject(proj.pid, { mockFixtureDir: dir });
  73 + sseBroker.close(proj.pid);
  74 + return { pid: proj.pid, cid, result, events };
  75 +}
  76 +
  77 +function cleanup(cid) {
  78 + try {
  79 + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
  80 + } catch { /* ignore */ }
  81 +}
  82 +
  83 +async function main() {
  84 + const cidsToClean = [];
  85 +
  86 + // ① contract_mismatch
  87 + let cm;
  88 + try {
  89 + await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => {
  90 + cm = await runScenario('contract_mismatch');
  91 + cidsToClean.push(cm.cid);
  92 + if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state);
  93 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8'));
  94 + if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds);
  95 + if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev);
  96 + return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` };
  97 + });
  98 +
  99 + await step(' sdd.updated SSE event', () => {
  100 + if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(','));
  101 + return { detail: 'sdd.updated emitted' };
  102 + });
  103 +
  104 + await step(' changelog has sdd_rev=2 entry', () => {
  105 + const cl = pathResolver.resolveChangelog(cm.pid);
  106 + if (!fs.existsSync(cl)) throw new Error('changelog missing');
  107 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  108 + if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog');
  109 + return { detail: `${lines.length} changelog entries` };
  110 + });
  111 +
  112 + await step(' bug opened → fixed', () => {
  113 + const bugs = bugTracker.list(cm.pid, {});
  114 + if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length);
  115 + if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status);
  116 + return { detail: `${bugs[0].id} (${bugs[0].status})` };
  117 + });
  118 + } catch { /* errors recorded by step */ }
  119 +
  120 + // ② impl_bug
  121 + let ib;
  122 + try {
  123 + await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => {
  124 + ib = await runScenario('impl_bug');
  125 + cidsToClean.push(ib.cid);
  126 + if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state);
  127 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8'));
  128 + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)');
  129 + if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)');
  130 + return { detail: `sdd_rev=1 l2_rounds=0` };
  131 + });
  132 + } catch {}
  133 +
  134 + // ③ spec_ambiguous
  135 + let sa;
  136 + try {
  137 + await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => {
  138 + sa = await runScenario('spec_ambiguous');
  139 + cidsToClean.push(sa.cid);
  140 + if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state);
  141 + if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE');
  142 + return { detail: 'l2_blocked · SSE fired' };
  143 + });
  144 + } catch {}
  145 +
  146 + // ④ infra_flake
  147 + let inf;
  148 + try {
  149 + await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => {
  150 + inf = await runScenario('infra_flake');
  151 + cidsToClean.push(inf.cid);
  152 + if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state);
  153 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8'));
  154 + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds);
  155 + const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  156 + if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action');
  157 + return { detail: 'shipped without budget burn' };
  158 + });
  159 + } catch {}
  160 +
  161 + // ⑤ Observability cross-check
  162 + try {
  163 + await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => {
  164 + // 用 contract_mismatch 项目验
  165 + if (!cm) throw new Error('cm scenario did not run');
  166 + const tl = pathResolver.resolveTimelineLog(cm.pid);
  167 + const bg = pathResolver.resolveBugsLog(cm.pid);
  168 + const cl = pathResolver.resolveChangelog(cm.pid);
  169 + const sl = pathResolver.resolveStreamLog(cm.pid);
  170 + for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) {
  171 + if (!fs.existsSync(p)) throw new Error(`${name} missing`);
  172 + }
  173 + const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean)
  174 + .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
  175 + for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) {
  176 + // unit.state is via sseBroker.emit not timeline; but we should at least have integration.result + sdd.updated as timeline kinds — actually those go via sseBroker.emit too. Check timeline has agent.call entries.
  177 + }
  178 + // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped
  179 + const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped'];
  180 + const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k));
  181 + if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(','));
  182 + return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` };
  183 + });
  184 +
  185 + await step('⑥ SSE replay via Last-Event-ID', async () => {
  186 + if (!cm) throw new Error('cm scenario did not run');
  187 + const replayed = [];
  188 + sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 });
  189 + sseBroker.close(cm.pid);
  190 + // 至少应回放出 sdd.updated / project.shipped 之类
  191 + if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length);
  192 + return { detail: `replayed ${replayed.length} events from id=3` };
  193 + });
  194 + } catch {}
  195 +
  196 + for (const c of cidsToClean) cleanup(c);
  197 +
  198 + let pass = 0, fail = 0;
  199 + console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────');
  200 + for (const r of results) {
  201 + const tag = r.ok ? '✓' : '✗';
  202 + console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`);
  203 + r.ok ? pass++ : fail++;
  204 + }
  205 + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  206 + process.exit(fail ? 1 : 0);
  207 +}
  208 +
  209 +main().catch(e => {
  210 + console.error('\n[phase3-verify] fatal:', e);
  211 + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  212 + process.exit(1);
  213 +});
... ...
server/agent-server.js
... ... @@ -67,6 +67,7 @@ app.use(&#39;/api/company&#39;, require(&#39;./routes/company&#39;));
67 67 app.use('/api/goals', require('./routes/goals'));
68 68 app.use('/api/tasks', require('./routes/tasks'));
69 69 app.use('/api/devloop', require('./routes/devloop'));
  70 +app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
70 71  
71 72 // Health
72 73 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
... ...
server/index.js
... ... @@ -94,6 +94,7 @@ app.use(&#39;/api/goals&#39;, require(&#39;./routes/goals&#39;));
94 94 app.use('/api/company', require('./routes/company'));
95 95 app.use('/api/tasks', require('./routes/tasks'));
96 96 app.use('/api/devloop', require('./routes/devloop'));
  97 +app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
97 98  
98 99 // ─── 系统端点 ───
99 100 app.get('/api/health', (req, res) => res.json({
... ...
server/public/devloop-status.html 0 → 100644
  1 +<!doctype html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="utf-8" />
  5 +<title>DevLoop · Project Status</title>
  6 +<style>
  7 + body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; }
  8 + header { display:flex; justify-content:space-between; align-items:baseline; border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:16px; }
  9 + h1 { font-size: 18px; margin: 0; }
  10 + .pid { color:#888; font-family:monospace; font-size:13px; }
  11 + .row { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:16px; }
  12 + .card { background:#f7f7f9; border:1px solid #e3e3e8; border-radius:6px; padding:12px 16px; min-width:160px; }
  13 + .card label { font-size:11px; text-transform:uppercase; color:#888; display:block; margin-bottom:4px; }
  14 + .card .v { font-size:20px; font-weight:600; }
  15 + .units { margin-top:8px; }
  16 + .unit { display:flex; align-items:center; padding:6px 10px; background:#fff; border:1px solid #e3e3e8; border-radius:4px; margin-bottom:4px; gap:12px; }
  17 + .unit .id { flex:1; font-family:monospace; }
  18 + .unit .deps { color:#888; font-size:12px; }
  19 + .status { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; text-transform:uppercase; }
  20 + .status.pending { background:#eee; color:#666; }
  21 + .status.coding { background:#fff3cd; color:#856404; }
  22 + .status.testing { background:#cce5ff; color:#004085; }
  23 + .status.passed { background:#d4edda; color:#155724; }
  24 + .status.failed { background:#f8d7da; color:#721c24; }
  25 + .status.blocked { background:#d6d8db; color:#383d41; }
  26 + .timeline { font-family:monospace; font-size:12px; max-height:240px; overflow:auto; background:#0f1115; color:#e6e6e6; padding:12px; border-radius:6px; }
  27 + .timeline .ts { color:#7da; margin-right:8px; }
  28 + .timeline .kind { color:#ffce6a; margin-right:8px; }
  29 + input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; }
  30 + button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; }
  31 + .err { color:#c00; margin:8px 0; }
  32 +</style>
  33 +</head>
  34 +<body>
  35 +<header>
  36 + <h1>DevLoop · Project Status</h1>
  37 + <div>
  38 + <input id="pidInput" placeholder="p-xxxxxx" />
  39 + <button id="connectBtn">Connect</button>
  40 + </div>
  41 +</header>
  42 +
  43 +<div id="err" class="err"></div>
  44 +
  45 +<div id="content" style="display:none">
  46 + <div class="pid">pid: <span id="pidShow"></span> · root: <span id="rootShow"></span></div>
  47 + <div class="row">
  48 + <div class="card"><label>PRD rev</label><div class="v" id="prdRev">-</div></div>
  49 + <div class="card"><label>SDD rev</label><div class="v" id="sddRev">-</div></div>
  50 + <div class="card"><label>Units</label><div class="v" id="unitsCount">0</div></div>
  51 + </div>
  52 + <h2 style="font-size:14px;margin:16px 0 8px">Units (DAG)</h2>
  53 + <div class="units" id="units"></div>
  54 + <h2 style="font-size:14px;margin:16px 0 8px">Recent timeline events</h2>
  55 + <div class="timeline" id="timeline"></div>
  56 +</div>
  57 +
  58 +<script>
  59 +const $ = id => document.getElementById(id);
  60 +let pollTimer = null;
  61 +
  62 +async function fetchState(pid) {
  63 + const r = await fetch(`/api/devloop/projects/${pid}/state`);
  64 + if (!r.ok) throw new Error(`state: ${r.status}`);
  65 + return r.json();
  66 +}
  67 +
  68 +function render(s) {
  69 + $('pidShow').textContent = s.pid;
  70 + $('rootShow').textContent = s.root_path || '';
  71 + $('prdRev').textContent = s.prd_rev ?? '-';
  72 + $('sddRev').textContent = s.sdd_rev ?? '-';
  73 + const units = s.units || [];
  74 + $('unitsCount').textContent = units.length;
  75 + $('units').innerHTML = units.map(u => `
  76 + <div class="unit">
  77 + <span class="id">${u.id}</span>
  78 + <span class="deps">deps: ${(u.depends_on || []).join(', ') || '∅'}</span>
  79 + <span class="status ${u.status}">${u.status}</span>
  80 + </div>`).join('');
  81 + $('timeline').innerHTML = (s.timeline_tail || []).map(e => `
  82 + <div><span class="ts">${e.ts || ''}</span><span class="kind">[${e.kind}]</span>${e.summary || ''}</div>
  83 + `).join('');
  84 +}
  85 +
  86 +async function connect() {
  87 + const pid = $('pidInput').value.trim();
  88 + if (!pid) return;
  89 + $('err').textContent = '';
  90 + if (pollTimer) clearInterval(pollTimer);
  91 + async function poll() {
  92 + try {
  93 + const s = await fetchState(pid);
  94 + render(s);
  95 + $('content').style.display = '';
  96 + } catch (e) {
  97 + $('err').textContent = e.message;
  98 + $('content').style.display = 'none';
  99 + }
  100 + }
  101 + await poll();
  102 + pollTimer = setInterval(poll, 2000);
  103 +}
  104 +
  105 +$('connectBtn').addEventListener('click', connect);
  106 +$('pidInput').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
  107 +const fromQuery = new URLSearchParams(location.search).get('pid');
  108 +if (fromQuery) { $('pidInput').value = fromQuery; connect(); }
  109 +</script>
  110 +</body>
  111 +</html>
... ...
server/routes/devloop.js
... ... @@ -20,6 +20,8 @@ const router = express.Router();
20 20  
21 21 const loopController = require('../src/core/devloop/loop-controller');
22 22 const pathResolver = require('../services/devloop/path-resolver');
  23 +const sseBroker = require('../services/devloop/sse-broker');
  24 +const bugTracker = require('../services/devloop/bug-tracker');
23 25  
24 26 const h = (fn) => async (req, res) => {
25 27 try { await fn(req, res); }
... ... @@ -76,38 +78,78 @@ router.get(&#39;/projects&#39;, h(async (_req, res) =&gt; {
76 78 res.json({ projects });
77 79 }));
78 80  
79   -// POST /api/devloop/projects/:pid/run?only=<unitId>
80   -// P2: only=<unitId> 跑单 unit;不带 only → 501 (P3 才补全 runProject)
  81 +// POST /api/devloop/projects/:pid/run[?only=<unitId>] [body: { mockFixtureDir? }]
  82 +// - 不带 only: 跑完整 runProject (P3)
  83 +// - only=<unitId>: 单 unit drill-down (P2 兼容)
81 84 router.post('/projects/:pid/run', h(async (req, res) => {
82 85 const { pid } = req.params;
83 86 const { only } = req.query;
84   - if (!only) {
85   - return res.status(501).json({ error: 'P2 only supports ?only=<unitId>; full project run lands in P3' });
86   - }
  87 + const { mockFixtureDir } = req.body || {};
87 88 try {
88   - const result = await loopController.runUnit(pid, only, { mock: true });
89   - res.json({
90   - ok: result.ok,
91   - pid, unit_id: only,
92   - attempts: result.attempts,
  89 + if (only) {
  90 + const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir });
  91 + return res.json({
  92 + ok: result.ok, pid, unit_id: only,
  93 + attempts: result.attempts, final_state: result.final_state,
  94 + history: result.history.map(h => ({
  95 + agent: h.agent, attempt: h.attempt,
  96 + ok: h.ok, files: h.files?.length || 0,
  97 + commit: h.commit || null,
  98 + })),
  99 + });
  100 + }
  101 + // 完整 runProject
  102 + const result = await loopController.runProject(pid, { mock: true, mockFixtureDir });
  103 + return res.json({
  104 + ok: result.ok, pid,
93 105 final_state: result.final_state,
94   - history: result.history.map(h => ({
95   - agent: h.agent, attempt: h.attempt,
96   - ok: h.ok, files: h.files?.length || 0,
97   - commit: h.commit || null,
98   - })),
  106 + l2_rounds: result.l2_rounds,
  107 + run_id: result.run_id || null,
  108 + reason: result.reason || null,
  109 + history: result.history,
99 110 });
100 111 } catch (e) {
101 112 if (/Unknown project/.test(e.message)) {
102 113 return res.status(404).json({ error: 'unknown project: ' + pid });
103 114 }
104 115 if (/not found in project/.test(e.message)) {
105   - return res.status(404).json({ error: 'unknown unit: ' + only });
  116 + return res.status(404).json({ error: 'unknown unit: ' + (only || '?') });
106 117 }
107 118 throw e;
108 119 }
109 120 }));
110 121  
  122 +// GET /api/devloop/projects/:pid/stream — SSE
  123 +// client may send Last-Event-ID header → broker replays from there
  124 +router.get('/projects/:pid/stream', (req, res) => {
  125 + const { pid } = req.params;
  126 + try { pathResolver.resolveProjectRoot(pid); }
  127 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  128 +
  129 + res.setHeader('Content-Type', 'text/event-stream');
  130 + res.setHeader('Cache-Control', 'no-cache');
  131 + res.setHeader('Connection', 'keep-alive');
  132 + res.flushHeaders?.();
  133 +
  134 + const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null;
  135 + sseBroker.subscribe(pid, res, {
  136 + replayFromOffset: lastEventId != null ? lastEventId : undefined,
  137 + });
  138 +});
  139 +
  140 +// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
  141 +router.get('/projects/:pid/bugs', h(async (req, res) => {
  142 + const { pid } = req.params;
  143 + try { pathResolver.resolveProjectRoot(pid); }
  144 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  145 + const filter = {};
  146 + if (req.query.status) filter.status = String(req.query.status).split(',');
  147 + if (req.query.unit_id) filter.unit_id = req.query.unit_id;
  148 + if (req.query.severity) filter.severity = String(req.query.severity).split(',');
  149 + const bugs = bugTracker.list(pid, filter);
  150 + res.json({ bugs, total: bugs.length });
  151 +}));
  152 +
111 153 // GET /api/devloop/projects/:pid/state
112 154 router.get('/projects/:pid/state', h(async (req, res) => {
113 155 try {
... ...
server/src/core/devloop/loop-controller.js
... ... @@ -24,8 +24,17 @@ const pathResolver = require(&#39;../../../services/devloop/path-resolver&#39;);
24 24 const gitVault = require('../../../services/devloop/git-vault');
25 25 const timelineLogger = require('../../../services/devloop/timeline-logger');
26 26 const unitRegistry = require('../../../services/devloop/unit-registry');
  27 +const bugTracker = require('../../../services/devloop/bug-tracker');
  28 +const changelogWriter = require('../../../services/devloop/changelog-writer');
  29 +const sseBroker = require('../../../services/devloop/sse-broker');
  30 +const docStore = require('../../../services/devloop/doc-store');
27 31 const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');
28 32  
  33 +// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地
  34 +unitRegistry.attachSseBroker(sseBroker);
  35 +bugTracker.attachSseBroker(sseBroker);
  36 +bugTracker.attachTimelineLogger(timelineLogger);
  37 +
29 38 const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');
30 39  
31 40 /**
... ... @@ -159,7 +168,10 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) {
159 168 // 2. 启动 protocol
160 169 const protocol = new ClaudeCliProtocol(
161 170 { name: agentName },
162   - { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段)
  171 + {
  172 + mock: opts.mock !== false, // 默认 mock=true (P1+ 阶段)
  173 + mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用
  174 + },
163 175 );
164 176  
165 177 let resultParams = null;
... ... @@ -246,17 +258,26 @@ async function runDocsPhase(pid, requirement, opts = {}) {
246 258 throw new Error('requirement (string) required');
247 259 }
248 260  
  261 + // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始
  262 + if (opts.resetMockSequence !== false) {
  263 + ClaudeCliProtocol.resetMockState();
  264 + }
  265 +
249 266 const onChunk = opts.onChunk
250 267 ? (agent) => (text) => opts.onChunk(agent, text)
251 268 : null;
  269 + const passthrough = {
  270 + mock: opts.mock !== false,
  271 + mockFixtureDir: opts.mockFixtureDir,
  272 + };
252 273  
253 274 const pm = await callAgent(pid, 'devloop-pm',
254 275 `Requirement:\n${requirement}\n\nmode: init`,
255   - { onChunk: onChunk ? onChunk('devloop-pm') : undefined });
  276 + { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined });
256 277  
257 278 const architect = await callAgent(pid, 'devloop-architect',
258 279 `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`,
259   - { onChunk: onChunk ? onChunk('devloop-architect') : undefined });
  280 + { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined });
260 281  
261 282 // timeline 总结
262 283 try {
... ... @@ -464,11 +485,272 @@ function _findLatestTesterResult(pid, unitId) {
464 485 }
465 486 }
466 487  
  488 +// ──────────────────────────────────────────────────────────────
  489 +// P3 · L2 Integration Loop · runProject
  490 +// ──────────────────────────────────────────────────────────────
  491 +
  492 +/**
  493 + * Topological wave grouping (Kahn). Returns string[][] — units per wave.
  494 + */
  495 +function _groupUnitsByWave(units) {
  496 + const groups = [];
  497 + const remaining = new Set(units.map(u => u.id));
  498 + const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()]));
  499 + while (remaining.size > 0) {
  500 + const wave = [];
  501 + for (const id of remaining) {
  502 + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
  503 + }
  504 + if (wave.length === 0) {
  505 + throw new Error('cycle in units.json (no unit has all dependencies cleared)');
  506 + }
  507 + for (const id of wave) remaining.delete(id);
  508 + groups.push(wave);
  509 + }
  510 + return groups;
  511 +}
  512 +
  513 +/** find latest run dir under integration/runs/ */
  514 +function _findLatestIntegrationRunDir(pid) {
  515 + const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs');
  516 + if (!fs.existsSync(dir)) return null;
  517 + const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory());
  518 + if (!ids.length) return null;
  519 + ids.sort();
  520 + return ids[ids.length - 1];
  521 +}
  522 +
  523 +function _readJsonSafe(p) {
  524 + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
  525 +}
  526 +
  527 +/**
  528 + * runProject — P3 主循环 (mock 模式)。
  529 + *
  530 + * 流程:
  531 + * while not done and not blocked:
  532 + * 1. 按 DAG wave 跑 L1 (per-unit runUnit)
  533 + * 2. 若 l2GateOpen → 跑 Integrator
  534 + * 3. result.ok=true → 项目完成
  535 + * 4. result.ok=false → Triager → dispatch:
  536 + * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json)
  537 + * → l2_rounds++; 超 l2_budget → l2_exhausted
  538 + * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算
  539 + * - infra_flake → 直接重跑 (不 reset, 不烧预算)
  540 + * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed
  541 + *
  542 + * @param {string} pid
  543 + * @param {object} opts
  544 + * - mockFixtureDir?: string (P3 scenario 测试用)
  545 + * - maxRetries?: number (safety net 防 mock 死循环;默认 10)
  546 + * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history }
  547 + */
  548 +async function runProject(pid, opts = {}) {
  549 + // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续,
  550 + // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。
  551 + // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。
  552 + const projectRoot = pathResolver.resolveProjectRoot(pid);
  553 + const history = [];
  554 + const callOpts = {
  555 + mock: opts.mock !== false,
  556 + mockFixtureDir: opts.mockFixtureDir,
  557 + resetMockSequence: false, // 一次 runProject 只 reset 一次
  558 + };
  559 + const safety = opts.maxRetries || 10;
  560 + let iterations = 0;
  561 +
  562 + // SSE start
  563 + sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() });
  564 +
  565 + while (iterations < safety) {
  566 + iterations++;
  567 +
  568 + // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ────────
  569 + const allUnits = unitRegistry.list(pid);
  570 + const pendingUnits = allUnits.filter(u => u.status === 'pending');
  571 + if (pendingUnits.length > 0) {
  572 + const waves = _groupUnitsByWave(pendingUnits);
  573 + for (let w = 0; w < waves.length; w++) {
  574 + const wave = waves[w];
  575 + sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave });
  576 + // 串行跑(mock 同步快,不需并行)
  577 + for (const uid of wave) {
  578 + const r = await runUnit(pid, uid, { ...callOpts });
  579 + history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts });
  580 + if (!r.ok) {
  581 + sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts });
  582 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` };
  583 + }
  584 + }
  585 + }
  586 + }
  587 +
  588 + // ── (2) L2 gate ────────────────────────────────────────────
  589 + if (!unitRegistry.l2GateOpen(pid)) {
  590 + // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked
  591 + sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' });
  592 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' };
  593 + }
  594 +
  595 + // ── (3) 跑 Integrator ──────────────────────────────────────
  596 + sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) });
  597 + const integ = await callAgent(pid, 'devloop-integrator',
  598 + `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`,
  599 + callOpts);
  600 + const runId = _findLatestIntegrationRunDir(pid);
  601 + const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null;
  602 + history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed });
  603 + sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed });
  604 +
  605 + // ── (4) 成功 → 项目完成 ────────────────────────────────────
  606 + if (intResult?.ok === true) {
  607 + // 若有未关 bug → 转 fixed
  608 + const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] });
  609 + for (const b of openBugs) {
  610 + try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); }
  611 + catch { /* skip invalid transitions */ }
  612 + }
  613 + sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId });
  614 + try {
  615 + timelineLogger.append(pid, {
  616 + source: 'system', kind: 'project.shipped', ref: runId,
  617 + summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`,
  618 + });
  619 + } catch {}
  620 + return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId };
  621 + }
  622 +
  623 + // ── (5) 失败 → 开 bug + 调 Triager ──────────────────────────
  624 + const bug = bugTracker.open(pid, {
  625 + source: 'integration_test',
  626 + run_id: runId,
  627 + title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`,
  628 + severity: intResult?.crashed ? 'high' : 'medium',
  629 + category: 'unknown',
  630 + detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200),
  631 + affected_units: intResult?.failures?.[0]?.involves || [],
  632 + by: 'devloop-integrator',
  633 + });
  634 + bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' });
  635 +
  636 + await callAgent(pid, 'devloop-triager',
  637 + `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`,
  638 + callOpts);
  639 + const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null;
  640 + history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence });
  641 + sseBroker.emit(pid, 'triage.result', {
  642 + run_id: runId,
  643 + category: triage?.category,
  644 + confidence: triage?.confidence,
  645 + affected_units: triage?.affected_units || [],
  646 + });
  647 +
  648 + if (!triage) {
  649 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId });
  650 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' };
  651 + }
  652 +
  653 + // ── (6) 分支调度 ────────────────────────────────────────────
  654 + const lowConfidence = (triage.confidence ?? 0) < 0.5;
  655 + const category = triage.category;
  656 +
  657 + if (lowConfidence || category === 'spec_ambiguous') {
  658 + sseBroker.emit(pid, 'human_intervention_needed', {
  659 + reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous',
  660 + run_id: runId, category, confidence: triage.confidence,
  661 + });
  662 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` };
  663 + }
  664 +
  665 + if (category === 'infra_flake') {
  666 + // 不 reset 不烧预算,直接下一轮重跑 Integrator
  667 + history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' });
  668 + continue;
  669 + }
  670 +
  671 + if (category === 'impl_bug') {
  672 + // reset affected units (不烧 l2_budget)
  673 + const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u));
  674 + if (affected.length === 0) {
  675 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId });
  676 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history };
  677 + }
  678 + unitRegistry.reset(pid, affected);
  679 + history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected });
  680 + sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' });
  681 + continue;
  682 + }
  683 +
  684 + if (category === 'contract_mismatch') {
  685 + // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++)
  686 + // 预检 l2_budget
  687 + const currentRounds = _l2Rounds(pid);
  688 + const l2Budget = _l2Budget(pid);
  689 + if (currentRounds + 1 > l2Budget) {
  690 + sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget });
  691 + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history };
  692 + }
  693 + const oldSddRev = _readSddRev(pid);
  694 + await callAgent(pid, 'devloop-architect',
  695 + `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`,
  696 + callOpts);
  697 + const newSddRev = _readSddRev(pid);
  698 + if (newSddRev > oldSddRev) {
  699 + try {
  700 + changelogWriter.append(pid, {
  701 + kind: 'sdd_rev',
  702 + rev: newSddRev,
  703 + by: 'devloop-architect',
  704 + trigger: 'l2_triage',
  705 + summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`,
  706 + diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] },
  707 + });
  708 + } catch { /* swallow */ }
  709 + sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` });
  710 + }
  711 + history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev });
  712 + continue;
  713 + }
  714 +
  715 + // Unknown category
  716 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category });
  717 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` };
  718 + }
  719 +
  720 + // safety net 超过
  721 + sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations });
  722 + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history };
  723 +}
  724 +
  725 +function _l2Rounds(pid) {
  726 + const u = docStore.readUnits(pid);
  727 + return u?.l2_rounds || 0;
  728 +}
  729 +function _l2Budget(pid) {
  730 + const u = docStore.readUnits(pid);
  731 + return u?.l2_budget || 3;
  732 +}
  733 +function _readSddRev(pid) {
  734 + const root = pathResolver.resolveProjectRoot(pid);
  735 + const idx = path.join(root, 'sdd', 'index.html');
  736 + if (!fs.existsSync(idx)) return 0;
  737 + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
  738 + if (m) return parseInt(m[1], 10);
  739 + // fallback: try mvcp/control.html (where patch goes)
  740 + const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html');
  741 + if (fs.existsSync(ctrl)) {
  742 + const m2 = fs.readFileSync(ctrl, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
  743 + if (m2) return parseInt(m2[1], 10);
  744 + }
  745 + return 1;
  746 +}
  747 +
467 748 module.exports = {
468 749 createProject,
469 750 commitAfterAgent,
470 751 callAgent,
471 752 runDocsPhase,
472 753 runUnit,
  754 + runProject,
473 755 getProjectState,
474 756 };
... ...
server/tests/devloop/routes.test.js
... ... @@ -106,9 +106,13 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
106 106 expect(s.units).toHaveLength(3);
107 107 });
108 108  
109   - it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => {
110   - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' });
111   - expect(r.status).toBe(501);
  109 + it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => {
  110 + const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, {
  111 + method: 'POST',
  112 + headers: { 'Content-Type': 'application/json' },
  113 + body: JSON.stringify({}),
  114 + });
  115 + expect(r.status).toBe(404);
112 116 });
113 117  
114 118 it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
... ... @@ -133,6 +137,32 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
133 137 expect(j2.history).toHaveLength(4);
134 138 });
135 139  
  140 + it('POST /run (full project, no ?only) drives runProject', async () => {
  141 + const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7);
  142 + created.push(cid);
  143 + const r1 = await fetch(`${base}/api/devloop/projects`, {
  144 + method: 'POST',
  145 + headers: { 'Content-Type': 'application/json' },
  146 + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }),
  147 + });
  148 + const pid = (await r1.json()).pid;
  149 + // ship via full project run (baseline fixtures = happy path)
  150 + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, {
  151 + method: 'POST',
  152 + headers: { 'Content-Type': 'application/json' },
  153 + body: JSON.stringify({}),
  154 + });
  155 + expect(r2.status).toBe(200);
  156 + const j = await r2.json();
  157 + expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state);
  158 + expect(typeof j.l2_rounds).toBe('number');
  159 + });
  160 +
  161 + it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => {
  162 + const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`);
  163 + expect(r.status).toBe(404);
  164 + });
  165 +
136 166 it('POST /run?only on unknown unit returns 404', async () => {
137 167 const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7);
138 168 created.push(cid);
... ...
server/tests/devloop/run-project.test.js 0 → 100644
  1 +/**
  2 + * loop-controller.runProject (P3 L2 联动) 单测
  3 + *
  4 + * 跑 4 个 L2 分支:
  5 + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1
  6 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0
  7 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
  8 + * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0
  9 + */
  10 +
  11 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  12 +
  13 +const fs = require('fs');
  14 +const path = require('path');
  15 +const os = require('os');
  16 +
  17 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-'));
  18 +process.env.HOME = TEST_HOME;
  19 +
  20 +const pathResolver = require('../../services/devloop/path-resolver');
  21 +const sseBroker = require('../../services/devloop/sse-broker');
  22 +const bugTracker = require('../../services/devloop/bug-tracker');
  23 +const lc = require('../../src/core/devloop/loop-controller');
  24 +
  25 +const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios');
  26 +
  27 +async function setupScenario(name) {
  28 + pathResolver._resetForTesting();
  29 + sseBroker._resetForTesting();
  30 + const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7);
  31 + const dir = path.join(SCENARIO_BASE, name);
  32 + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name });
  33 + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
  34 + return { pid: proj.pid, cid, dir };
  35 +}
  36 +
  37 +function cleanup(cid) {
  38 + try {
  39 + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
  40 + } catch { /* ignore */ }
  41 +}
  42 +
  43 +describe('runProject · L2 branches', () => {
  44 + describe('① contract_mismatch', () => {
  45 + let pid, cid, dir, result;
  46 + const sseEvents = [];
  47 +
  48 + beforeAll(async () => {
  49 + ({ pid, cid, dir } = await setupScenario('contract_mismatch'));
  50 + const fakeRes = {
  51 + setHeader() {}, flushHeaders() {}, end() {},
  52 + on() {}, // sse-broker subscribes res.on('close', ...)
  53 + write(line) {
  54 + const m = line.match(/^event: (.+)$/m);
  55 + if (m) sseEvents.push(m[1]);
  56 + },
  57 + };
  58 + sseBroker.subscribe(pid, fakeRes, {});
  59 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  60 + });
  61 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  62 +
  63 + it('ships after 1 L2 round', () => {
  64 + expect(result.ok).toBe(true);
  65 + expect(result.final_state).toBe('shipped');
  66 + });
  67 +
  68 + it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => {
  69 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  70 + expect(u.l2_rounds).toBe(1);
  71 + expect(u.sdd_rev).toBe('2');
  72 + });
  73 +
  74 + it('emits sdd.updated event', () => {
  75 + expect(sseEvents).toContain('sdd.updated');
  76 + });
  77 +
  78 + it('changelog has sdd_rev entry', () => {
  79 + const cl = pathResolver.resolveChangelog(pid);
  80 + expect(fs.existsSync(cl)).toBe(true);
  81 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  82 + expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true);
  83 + });
  84 +
  85 + it('bug opened then transitioned to fixed', () => {
  86 + const bugs = bugTracker.list(pid, {});
  87 + expect(bugs).toHaveLength(1);
  88 + expect(bugs[0].status).toBe('fixed');
  89 + });
  90 + });
  91 +
  92 + describe('② impl_bug', () => {
  93 + let pid, cid, dir, result;
  94 +
  95 + beforeAll(async () => {
  96 + ({ pid, cid, dir } = await setupScenario('impl_bug'));
  97 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  98 + });
  99 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  100 +
  101 + it('ships without SDD change', () => {
  102 + expect(result.ok).toBe(true);
  103 + expect(result.final_state).toBe('shipped');
  104 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  105 + expect(u.l2_rounds).toBe(0);
  106 + expect(u.sdd_rev).toBe('1');
  107 + });
  108 +
  109 + it('history shows impl_bug reset_units (no sdd_patch)', () => {
  110 + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  111 + expect(actions.some(a => /impl_bug/.test(a))).toBe(true);
  112 + expect(actions.some(a => /sdd_patch/.test(a))).toBe(false);
  113 + });
  114 + });
  115 +
  116 + describe('③ spec_ambiguous', () => {
  117 + let pid, cid, dir, result;
  118 + const sseEvents = [];
  119 +
  120 + beforeAll(async () => {
  121 + ({ pid, cid, dir } = await setupScenario('spec_ambiguous'));
  122 + const fakeRes = {
  123 + setHeader() {}, flushHeaders() {}, end() {},
  124 + on() {}, // sse-broker subscribes res.on('close', ...)
  125 + write(line) {
  126 + const m = line.match(/^event: (.+)$/m);
  127 + if (m) sseEvents.push(m[1]);
  128 + },
  129 + };
  130 + sseBroker.subscribe(pid, fakeRes, {});
  131 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  132 + });
  133 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  134 +
  135 + it('returns l2_blocked', () => {
  136 + expect(result.ok).toBe(false);
  137 + expect(result.final_state).toBe('l2_blocked');
  138 + });
  139 +
  140 + it('emits human_intervention_needed SSE', () => {
  141 + expect(sseEvents).toContain('human_intervention_needed');
  142 + });
  143 + });
  144 +
  145 + describe('④ infra_flake', () => {
  146 + let pid, cid, dir, result;
  147 +
  148 + beforeAll(async () => {
  149 + ({ pid, cid, dir } = await setupScenario('infra_flake'));
  150 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  151 + });
  152 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  153 +
  154 + it('ships after re-run without budget burn', () => {
  155 + expect(result.ok).toBe(true);
  156 + expect(result.final_state).toBe('shipped');
  157 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  158 + expect(u.l2_rounds).toBe(0);
  159 + });
  160 +
  161 + it('history records rerun_integrator', () => {
  162 + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  163 + expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true);
  164 + });
  165 + });
  166 +});
... ...