diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json new file mode 100644 index 0000000..dd26159 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json @@ -0,0 +1,59 @@ +{ + "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).", + "sequence": [ + { + "_variant": "init", + "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control rev1

C-1 POST /todos

201 on create (ambiguous on errors)

text
POST
v1
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap1

rev1

" + } + ] + }, + { + "_variant": "patch", + "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"], + "result": "mock-ok devloop-architect[patch]: SDD rev 2", + "files": [ + { + "path": "sdd/mvcp/control.html", + "content": "Control rev 2

C-1 POST /todos (clarified)

201 on create; 400 on missing text

textrequired
POST
v2
" + }, + { + "path": "sdd/index.html", + "content": "SDD rev2

SDD

rev 2

m
m
x
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "history/sdd-rev-002.html", + "content": "

snap2

patch round 1

" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json new file mode 100644 index 0000000..9c990ea --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json @@ -0,0 +1,42 @@ +{ + "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.", + "sequence": [ + { + "_variant": "round1-fail", + "chunks": [ + "## step 0 · 读 units.json + integration/spec.json\n", + "## step 1-4 · 起 be 进程,跑 case-add-list\n", + "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n" + ], + "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n" + }, + { + "path": "integration/runs/run-mock-l2-1/http-trace.ndjson", + "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": [ + "## L2 round 2 (after SDD patched + L1 reran)\n", + "case-add-list 通过 ✓\n" + ], + "result": "mock-ok devloop-integrator[round2] PASS: integration green", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json new file mode 100644 index 0000000..aa2749e --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json @@ -0,0 +1,16 @@ +{ + "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).", + "chunks": [ + "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n", + "## step 1 · category_classifier → contract_mismatch\n", + "## step 2 · 起草 proposed_sdd_patch\n", + "## step 3 · 写 triage.json + evidence 自检\n" + ], + "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json new file mode 100644 index 0000000..15078b5 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json @@ -0,0 +1,31 @@ +{ + "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.", + "sequence": [ + { + "_variant": "round1-fail", + "chunks": ["## case fail: todo-be assertion mismatch\n"], + "result": "mock-ok devloop-integrator[round1]: impl_bug failure", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] ReferenceError at line 8\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": ["## pass after coder reset\n"], + "result": "mock-ok devloop-integrator[round2]: pass", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json new file mode 100644 index 0000000..646a68c --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json @@ -0,0 +1,13 @@ +{ + "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。", + "chunks": [ + "## category_classifier → impl_bug (SDD 明确,code 实现错)\n" + ], + "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json new file mode 100644 index 0000000..97b9d25 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json @@ -0,0 +1,31 @@ +{ + "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).", + "sequence": [ + { + "_variant": "round1-crash", + "chunks": ["## EADDRINUSE on :18001\n"], + "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": ["## retry: new port, pass\n"], + "result": "mock-ok devloop-integrator[round2]: pass", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json new file mode 100644 index 0000000..3923410 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json @@ -0,0 +1,11 @@ +{ + "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.", + "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"], + "result": "mock-ok devloop-triager: infra_flake confidence=0.95", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json new file mode 100644 index 0000000..4aba5a9 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json @@ -0,0 +1,11 @@ +{ + "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.", + "chunks": ["## case fail (spec_ambiguous)\n"], + "result": "mock-ok devloop-integrator: failure due to ambiguous SDD", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json new file mode 100644 index 0000000..07c7ef2 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json @@ -0,0 +1,11 @@ +{ + "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.", + "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"], + "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js index 3d85913..c6edeb8 100644 --- a/packages/acp-core/protocol/ClaudeCliProtocol.js +++ b/packages/acp-core/protocol/ClaudeCliProtocol.js @@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter { this._mockMode = MOCK_MODE || options.mock === true; this._mockFixtureDir = options.mockFixtureDir || path.resolve(__dirname, '../../../fixtures/devloop-mock'); + // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录 + this._mockFallbackDir = options.mockFallbackDir + || path.resolve(__dirname, '../../../fixtures/devloop-mock'); } /** @@ -536,21 +539,25 @@ class ClaudeCliProtocol extends EventEmitter { async _sendMockPrompt(text) { const agentName = this.agentInfo?.name || this.options.agentName || 'default'; const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`); - const defaultPath = path.join(this._mockFixtureDir, 'default.json'); + const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir + ? path.join(this._mockFallbackDir, `${agentName}.json`) + : null; + const defaultPath = path.join(this._mockFallbackDir, 'default.json'); let raw; - if (fs.existsSync(fixturePath)) { + const tryPaths = [fixturePath]; + if (fallbackPath) tryPaths.push(fallbackPath); + tryPaths.push(defaultPath); + + for (const p of tryPaths) { + if (!fs.existsSync(p)) continue; try { - raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + raw = JSON.parse(fs.readFileSync(p, 'utf8')); + break; } catch (e) { - console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`); + console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`); } } - if (!raw && fs.existsSync(defaultPath)) { - try { - raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); - } catch { /* ignore */ } - } if (!raw) { // echo fallback: 把 prompt 头 80 字符回显 raw = { diff --git a/scripts/phase3-verify.js b/scripts/phase3-verify.js new file mode 100644 index 0000000..7807dd3 --- /dev/null +++ b/scripts/phase3-verify.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * scripts/phase3-verify.js + * + * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支): + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE + * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset + * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay + * + * 用法: node scripts/phase3-verify.js + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const sseBroker = require('../server/services/devloop/sse-broker'); +const bugTracker = require('../server/services/devloop/bug-tracker'); +const lc = require('../server/src/core/devloop/loop-controller'); + +const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios'); + +const results = []; +function step(name, fn) { + return (async () => { + try { + const r = await fn(); + results.push({ name, ok: true, detail: r?.detail || '' }); + return r; + } catch (e) { + results.push({ name, ok: false, reason: e.message }); + throw e; + } + })(); +} + +function makeFakeRes(events) { + return { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) events.push(m[1]); + }, + }; +} + +async function runScenario(scenario) { + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36); + const dir = path.join(SCENARIO_BASE, scenario); + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario }); + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); + + const events = []; + sseBroker.subscribe(proj.pid, makeFakeRes(events), {}); + + const result = await lc.runProject(proj.pid, { mockFixtureDir: dir }); + sseBroker.close(proj.pid); + return { pid: proj.pid, cid, result, events }; +} + +function cleanup(cid) { + try { + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); + } catch { /* ignore */ } +} + +async function main() { + const cidsToClean = []; + + // ① contract_mismatch + let cm; + try { + await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => { + cm = await runScenario('contract_mismatch'); + cidsToClean.push(cm.cid); + if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8')); + if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds); + if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev); + return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` }; + }); + + await step(' sdd.updated SSE event', () => { + if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(',')); + return { detail: 'sdd.updated emitted' }; + }); + + await step(' changelog has sdd_rev=2 entry', () => { + const cl = pathResolver.resolveChangelog(cm.pid); + if (!fs.existsSync(cl)) throw new Error('changelog missing'); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog'); + return { detail: `${lines.length} changelog entries` }; + }); + + await step(' bug opened → fixed', () => { + const bugs = bugTracker.list(cm.pid, {}); + if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length); + if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status); + return { detail: `${bugs[0].id} (${bugs[0].status})` }; + }); + } catch { /* errors recorded by step */ } + + // ② impl_bug + let ib; + try { + await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => { + ib = await runScenario('impl_bug'); + cidsToClean.push(ib.cid); + if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8')); + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)'); + if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)'); + return { detail: `sdd_rev=1 l2_rounds=0` }; + }); + } catch {} + + // ③ spec_ambiguous + let sa; + try { + await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => { + sa = await runScenario('spec_ambiguous'); + cidsToClean.push(sa.cid); + if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state); + if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE'); + return { detail: 'l2_blocked · SSE fired' }; + }); + } catch {} + + // ④ infra_flake + let inf; + try { + await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => { + inf = await runScenario('infra_flake'); + cidsToClean.push(inf.cid); + if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8')); + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds); + const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action'); + return { detail: 'shipped without budget burn' }; + }); + } catch {} + + // ⑤ Observability cross-check + try { + await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => { + // 用 contract_mismatch 项目验 + if (!cm) throw new Error('cm scenario did not run'); + const tl = pathResolver.resolveTimelineLog(cm.pid); + const bg = pathResolver.resolveBugsLog(cm.pid); + const cl = pathResolver.resolveChangelog(cm.pid); + const sl = pathResolver.resolveStreamLog(cm.pid); + for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) { + if (!fs.existsSync(p)) throw new Error(`${name} missing`); + } + const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean) + .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }}); + for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) { + // 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. + } + // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped + const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped']; + const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k)); + if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(',')); + return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` }; + }); + + await step('⑥ SSE replay via Last-Event-ID', async () => { + if (!cm) throw new Error('cm scenario did not run'); + const replayed = []; + sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 }); + sseBroker.close(cm.pid); + // 至少应回放出 sdd.updated / project.shipped 之类 + if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length); + return { detail: `replayed ${replayed.length} events from id=3` }; + }); + } catch {} + + for (const c of cidsToClean) cleanup(c); + + let pass = 0, fail = 0; + console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`); + r.ok ? pass++ : fail++; + } + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); + process.exit(fail ? 1 : 0); +} + +main().catch(e => { + console.error('\n[phase3-verify] fatal:', e); + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); + process.exit(1); +}); diff --git a/server/agent-server.js b/server/agent-server.js index e2c5e40..5502f43 100644 --- a/server/agent-server.js +++ b/server/agent-server.js @@ -67,6 +67,7 @@ app.use('/api/company', require('./routes/company')); app.use('/api/goals', require('./routes/goals')); app.use('/api/tasks', require('./routes/tasks')); app.use('/api/devloop', require('./routes/devloop')); +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); diff --git a/server/index.js b/server/index.js index e23c94d..3064fe9 100644 --- a/server/index.js +++ b/server/index.js @@ -94,6 +94,7 @@ app.use('/api/goals', require('./routes/goals')); app.use('/api/company', require('./routes/company')); app.use('/api/tasks', require('./routes/tasks')); app.use('/api/devloop', require('./routes/devloop')); +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); // ─── 系统端点 ─── app.get('/api/health', (req, res) => res.json({ diff --git a/server/public/devloop-status.html b/server/public/devloop-status.html new file mode 100644 index 0000000..c01d685 --- /dev/null +++ b/server/public/devloop-status.html @@ -0,0 +1,111 @@ + + + + +DevLoop · Project Status + + + +
+

DevLoop · Project Status

+
+ + +
+
+ +
+ + + + + + diff --git a/server/routes/devloop.js b/server/routes/devloop.js index 6da93a5..297f894 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -20,6 +20,8 @@ const router = express.Router(); const loopController = require('../src/core/devloop/loop-controller'); const pathResolver = require('../services/devloop/path-resolver'); +const sseBroker = require('../services/devloop/sse-broker'); +const bugTracker = require('../services/devloop/bug-tracker'); const h = (fn) => async (req, res) => { try { await fn(req, res); } @@ -76,38 +78,78 @@ router.get('/projects', h(async (_req, res) => { res.json({ projects }); })); -// POST /api/devloop/projects/:pid/run?only= -// P2: only= 跑单 unit;不带 only → 501 (P3 才补全 runProject) +// POST /api/devloop/projects/:pid/run[?only=] [body: { mockFixtureDir? }] +// - 不带 only: 跑完整 runProject (P3) +// - only=: 单 unit drill-down (P2 兼容) router.post('/projects/:pid/run', h(async (req, res) => { const { pid } = req.params; const { only } = req.query; - if (!only) { - return res.status(501).json({ error: 'P2 only supports ?only=; full project run lands in P3' }); - } + const { mockFixtureDir } = req.body || {}; try { - const result = await loopController.runUnit(pid, only, { mock: true }); - res.json({ - ok: result.ok, - pid, unit_id: only, - attempts: result.attempts, + if (only) { + const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir }); + return res.json({ + ok: result.ok, pid, unit_id: only, + attempts: result.attempts, final_state: result.final_state, + history: result.history.map(h => ({ + agent: h.agent, attempt: h.attempt, + ok: h.ok, files: h.files?.length || 0, + commit: h.commit || null, + })), + }); + } + // 完整 runProject + const result = await loopController.runProject(pid, { mock: true, mockFixtureDir }); + return res.json({ + ok: result.ok, pid, final_state: result.final_state, - history: result.history.map(h => ({ - agent: h.agent, attempt: h.attempt, - ok: h.ok, files: h.files?.length || 0, - commit: h.commit || null, - })), + l2_rounds: result.l2_rounds, + run_id: result.run_id || null, + reason: result.reason || null, + history: result.history, }); } catch (e) { if (/Unknown project/.test(e.message)) { return res.status(404).json({ error: 'unknown project: ' + pid }); } if (/not found in project/.test(e.message)) { - return res.status(404).json({ error: 'unknown unit: ' + only }); + return res.status(404).json({ error: 'unknown unit: ' + (only || '?') }); } throw e; } })); +// GET /api/devloop/projects/:pid/stream — SSE +// client may send Last-Event-ID header → broker replays from there +router.get('/projects/:pid/stream', (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders?.(); + + const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null; + sseBroker.subscribe(pid, res, { + replayFromOffset: lastEventId != null ? lastEventId : undefined, + }); +}); + +// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be +router.get('/projects/:pid/bugs', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const filter = {}; + if (req.query.status) filter.status = String(req.query.status).split(','); + if (req.query.unit_id) filter.unit_id = req.query.unit_id; + if (req.query.severity) filter.severity = String(req.query.severity).split(','); + const bugs = bugTracker.list(pid, filter); + res.json({ bugs, total: bugs.length }); +})); + // GET /api/devloop/projects/:pid/state router.get('/projects/:pid/state', h(async (req, res) => { try { diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js index c4092d9..faaaf43 100644 --- a/server/src/core/devloop/loop-controller.js +++ b/server/src/core/devloop/loop-controller.js @@ -24,8 +24,17 @@ const pathResolver = require('../../../services/devloop/path-resolver'); const gitVault = require('../../../services/devloop/git-vault'); const timelineLogger = require('../../../services/devloop/timeline-logger'); const unitRegistry = require('../../../services/devloop/unit-registry'); +const bugTracker = require('../../../services/devloop/bug-tracker'); +const changelogWriter = require('../../../services/devloop/changelog-writer'); +const sseBroker = require('../../../services/devloop/sse-broker'); +const docStore = require('../../../services/devloop/doc-store'); const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); +// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地 +unitRegistry.attachSseBroker(sseBroker); +bugTracker.attachSseBroker(sseBroker); +bugTracker.attachTimelineLogger(timelineLogger); + const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); /** @@ -159,7 +168,10 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) { // 2. 启动 protocol const protocol = new ClaudeCliProtocol( { name: agentName }, - { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段) + { + mock: opts.mock !== false, // 默认 mock=true (P1+ 阶段) + mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用 + }, ); let resultParams = null; @@ -246,17 +258,26 @@ async function runDocsPhase(pid, requirement, opts = {}) { throw new Error('requirement (string) required'); } + // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始 + if (opts.resetMockSequence !== false) { + ClaudeCliProtocol.resetMockState(); + } + const onChunk = opts.onChunk ? (agent) => (text) => opts.onChunk(agent, text) : null; + const passthrough = { + mock: opts.mock !== false, + mockFixtureDir: opts.mockFixtureDir, + }; const pm = await callAgent(pid, 'devloop-pm', `Requirement:\n${requirement}\n\nmode: init`, - { onChunk: onChunk ? onChunk('devloop-pm') : undefined }); + { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined }); const architect = await callAgent(pid, 'devloop-architect', `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`, - { onChunk: onChunk ? onChunk('devloop-architect') : undefined }); + { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined }); // timeline 总结 try { @@ -464,11 +485,272 @@ function _findLatestTesterResult(pid, unitId) { } } +// ────────────────────────────────────────────────────────────── +// P3 · L2 Integration Loop · runProject +// ────────────────────────────────────────────────────────────── + +/** + * Topological wave grouping (Kahn). Returns string[][] — units per wave. + */ +function _groupUnitsByWave(units) { + const groups = []; + const remaining = new Set(units.map(u => u.id)); + const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()])); + while (remaining.size > 0) { + const wave = []; + for (const id of remaining) { + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); + } + if (wave.length === 0) { + throw new Error('cycle in units.json (no unit has all dependencies cleared)'); + } + for (const id of wave) remaining.delete(id); + groups.push(wave); + } + return groups; +} + +/** find latest run dir under integration/runs/ */ +function _findLatestIntegrationRunDir(pid) { + const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs'); + if (!fs.existsSync(dir)) return null; + const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory()); + if (!ids.length) return null; + ids.sort(); + return ids[ids.length - 1]; +} + +function _readJsonSafe(p) { + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } +} + +/** + * runProject — P3 主循环 (mock 模式)。 + * + * 流程: + * while not done and not blocked: + * 1. 按 DAG wave 跑 L1 (per-unit runUnit) + * 2. 若 l2GateOpen → 跑 Integrator + * 3. result.ok=true → 项目完成 + * 4. result.ok=false → Triager → dispatch: + * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json) + * → l2_rounds++; 超 l2_budget → l2_exhausted + * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算 + * - infra_flake → 直接重跑 (不 reset, 不烧预算) + * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed + * + * @param {string} pid + * @param {object} opts + * - mockFixtureDir?: string (P3 scenario 测试用) + * - maxRetries?: number (safety net 防 mock 死循环;默认 10) + * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history } + */ +async function runProject(pid, opts = {}) { + // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续, + // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。 + // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。 + const projectRoot = pathResolver.resolveProjectRoot(pid); + const history = []; + const callOpts = { + mock: opts.mock !== false, + mockFixtureDir: opts.mockFixtureDir, + resetMockSequence: false, // 一次 runProject 只 reset 一次 + }; + const safety = opts.maxRetries || 10; + let iterations = 0; + + // SSE start + sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() }); + + while (iterations < safety) { + iterations++; + + // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ──────── + const allUnits = unitRegistry.list(pid); + const pendingUnits = allUnits.filter(u => u.status === 'pending'); + if (pendingUnits.length > 0) { + const waves = _groupUnitsByWave(pendingUnits); + for (let w = 0; w < waves.length; w++) { + const wave = waves[w]; + sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave }); + // 串行跑(mock 同步快,不需并行) + for (const uid of wave) { + const r = await runUnit(pid, uid, { ...callOpts }); + history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); + if (!r.ok) { + sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; + } + } + } + } + + // ── (2) L2 gate ──────────────────────────────────────────── + if (!unitRegistry.l2GateOpen(pid)) { + // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked + sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; + } + + // ── (3) 跑 Integrator ────────────────────────────────────── + sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) }); + const integ = await callAgent(pid, 'devloop-integrator', + `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`, + callOpts); + const runId = _findLatestIntegrationRunDir(pid); + const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null; + history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed }); + sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed }); + + // ── (4) 成功 → 项目完成 ──────────────────────────────────── + if (intResult?.ok === true) { + // 若有未关 bug → 转 fixed + const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] }); + for (const b of openBugs) { + try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); } + catch { /* skip invalid transitions */ } + } + sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId }); + try { + timelineLogger.append(pid, { + source: 'system', kind: 'project.shipped', ref: runId, + summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, + }); + } catch {} + return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; + } + + // ── (5) 失败 → 开 bug + 调 Triager ────────────────────────── + const bug = bugTracker.open(pid, { + source: 'integration_test', + run_id: runId, + title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`, + severity: intResult?.crashed ? 'high' : 'medium', + category: 'unknown', + detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200), + affected_units: intResult?.failures?.[0]?.involves || [], + by: 'devloop-integrator', + }); + bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' }); + + await callAgent(pid, 'devloop-triager', + `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`, + callOpts); + const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null; + history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence }); + sseBroker.emit(pid, 'triage.result', { + run_id: runId, + category: triage?.category, + confidence: triage?.confidence, + affected_units: triage?.affected_units || [], + }); + + if (!triage) { + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; + } + + // ── (6) 分支调度 ──────────────────────────────────────────── + const lowConfidence = (triage.confidence ?? 0) < 0.5; + const category = triage.category; + + if (lowConfidence || category === 'spec_ambiguous') { + sseBroker.emit(pid, 'human_intervention_needed', { + reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', + run_id: runId, category, confidence: triage.confidence, + }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; + } + + if (category === 'infra_flake') { + // 不 reset 不烧预算,直接下一轮重跑 Integrator + history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' }); + continue; + } + + if (category === 'impl_bug') { + // reset affected units (不烧 l2_budget) + const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); + if (affected.length === 0) { + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; + } + unitRegistry.reset(pid, affected); + history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected }); + sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' }); + continue; + } + + if (category === 'contract_mismatch') { + // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++) + // 预检 l2_budget + const currentRounds = _l2Rounds(pid); + const l2Budget = _l2Budget(pid); + if (currentRounds + 1 > l2Budget) { + sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; + } + const oldSddRev = _readSddRev(pid); + await callAgent(pid, 'devloop-architect', + `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`, + callOpts); + const newSddRev = _readSddRev(pid); + if (newSddRev > oldSddRev) { + try { + changelogWriter.append(pid, { + kind: 'sdd_rev', + rev: newSddRev, + by: 'devloop-architect', + trigger: 'l2_triage', + summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`, + diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] }, + }); + } catch { /* swallow */ } + sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` }); + } + history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev }); + continue; + } + + // Unknown category + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; + } + + // safety net 超过 + sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; +} + +function _l2Rounds(pid) { + const u = docStore.readUnits(pid); + return u?.l2_rounds || 0; +} +function _l2Budget(pid) { + const u = docStore.readUnits(pid); + return u?.l2_budget || 3; +} +function _readSddRev(pid) { + const root = pathResolver.resolveProjectRoot(pid); + const idx = path.join(root, 'sdd', 'index.html'); + if (!fs.existsSync(idx)) return 0; + const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); + if (m) return parseInt(m[1], 10); + // fallback: try mvcp/control.html (where patch goes) + const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html'); + if (fs.existsSync(ctrl)) { + const m2 = fs.readFileSync(ctrl, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); + if (m2) return parseInt(m2[1], 10); + } + return 1; +} + module.exports = { createProject, commitAfterAgent, callAgent, runDocsPhase, runUnit, + runProject, getProjectState, }; diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js index 25cc2fc..7fe2227 100644 --- a/server/tests/devloop/routes.test.js +++ b/server/tests/devloop/routes.test.js @@ -106,9 +106,13 @@ describe('routes/devloop', () => { expect(s.units).toHaveLength(3); }); - it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' }); - expect(r.status).toBe(501); + it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(r.status).toBe(404); }); it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { @@ -133,6 +137,32 @@ describe('routes/devloop', () => { expect(j2.history).toHaveLength(4); }); + it('POST /run (full project, no ?only) drives runProject', async () => { + const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7); + created.push(cid); + const r1 = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }), + }); + const pid = (await r1.json()).pid; + // ship via full project run (baseline fixtures = happy path) + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(r2.status).toBe(200); + const j = await r2.json(); + expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state); + expect(typeof j.l2_rounds).toBe('number'); + }); + + it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`); + expect(r.status).toBe(404); + }); + it('POST /run?only on unknown unit returns 404', async () => { const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7); created.push(cid); diff --git a/server/tests/devloop/run-project.test.js b/server/tests/devloop/run-project.test.js new file mode 100644 index 0000000..22e21f0 --- /dev/null +++ b/server/tests/devloop/run-project.test.js @@ -0,0 +1,166 @@ +/** + * loop-controller.runProject (P3 L2 联动) 单测 + * + * 跑 4 个 L2 分支: + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE + * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../services/devloop/path-resolver'); +const sseBroker = require('../../services/devloop/sse-broker'); +const bugTracker = require('../../services/devloop/bug-tracker'); +const lc = require('../../src/core/devloop/loop-controller'); + +const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios'); + +async function setupScenario(name) { + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7); + const dir = path.join(SCENARIO_BASE, name); + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name }); + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); + return { pid: proj.pid, cid, dir }; +} + +function cleanup(cid) { + try { + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); + } catch { /* ignore */ } +} + +describe('runProject · L2 branches', () => { + describe('① contract_mismatch', () => { + let pid, cid, dir, result; + const sseEvents = []; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('contract_mismatch')); + const fakeRes = { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, // sse-broker subscribes res.on('close', ...) + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) sseEvents.push(m[1]); + }, + }; + sseBroker.subscribe(pid, fakeRes, {}); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships after 1 L2 round', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + }); + + it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => { + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(1); + expect(u.sdd_rev).toBe('2'); + }); + + it('emits sdd.updated event', () => { + expect(sseEvents).toContain('sdd.updated'); + }); + + it('changelog has sdd_rev entry', () => { + const cl = pathResolver.resolveChangelog(pid); + expect(fs.existsSync(cl)).toBe(true); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true); + }); + + it('bug opened then transitioned to fixed', () => { + const bugs = bugTracker.list(pid, {}); + expect(bugs).toHaveLength(1); + expect(bugs[0].status).toBe('fixed'); + }); + }); + + describe('② impl_bug', () => { + let pid, cid, dir, result; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('impl_bug')); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships without SDD change', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(0); + expect(u.sdd_rev).toBe('1'); + }); + + it('history shows impl_bug reset_units (no sdd_patch)', () => { + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + expect(actions.some(a => /impl_bug/.test(a))).toBe(true); + expect(actions.some(a => /sdd_patch/.test(a))).toBe(false); + }); + }); + + describe('③ spec_ambiguous', () => { + let pid, cid, dir, result; + const sseEvents = []; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('spec_ambiguous')); + const fakeRes = { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, // sse-broker subscribes res.on('close', ...) + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) sseEvents.push(m[1]); + }, + }; + sseBroker.subscribe(pid, fakeRes, {}); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('returns l2_blocked', () => { + expect(result.ok).toBe(false); + expect(result.final_state).toBe('l2_blocked'); + }); + + it('emits human_intervention_needed SSE', () => { + expect(sseEvents).toContain('human_intervention_needed'); + }); + }); + + describe('④ infra_flake', () => { + let pid, cid, dir, result; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('infra_flake')); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships after re-run without budget burn', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(0); + }); + + it('history records rerun_integrator', () => { + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true); + }); + }); +});