Commit 151acb2247295a8023745f9eb586aef3dd4275b5
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>
Showing
21 changed files
with
1202 additions
and
31 deletions
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('/api/company', require('./routes/company')); |
| 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('/api/goals', require('./routes/goals')); |
| 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('/projects', h(async (_req, res) => { |
| 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('../../../services/devloop/path-resolver'); |
| 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('routes/devloop', () => { |
| 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('routes/devloop', () => { |
| 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 | +}); | ... | ... |