Commit 3c14a4dd35822b5dc56cb7b0f3ca37bf41dc31b8
1 parent
bd1636b3
feat(devloop): P2 L1 single-unit loop — Coder + Tester with retry-with-reflection
Implements the §P2 acceptance gate from plan-migration.md: a single
POST /api/devloop/projects/:pid/run?only=todo-be drives the L1 inner
loop (Coder → Tester → fail → Coder → Tester → pass), with the second
Coder pass demonstrably reflecting on the first failure.
Mock protocol — stateful sequences:
- packages/acp-core/protocol/ClaudeCliProtocol.js
* Mock fixtures now support `sequence: [{chunks,result,files}, ...]`.
Each protocol.connect() + sendPrompt() consumes the next variant;
sticks on the last entry after exhausted.
* Static `ClaudeCliProtocol.resetMockState()` clears counters between
test runs.
Fixtures:
- fixtures/devloop-mock/devloop-architect.json — units.json now has
status:pending + retries:0 + retry_budget per unit, plus
units/{todo-be,todo-db,todo-fe}/spec.json so Tester has acceptance
criteria to read.
- fixtures/devloop-mock/devloop-coder.json — sequence: implement (with
intentional missing 400 branch) → retry (reflective fix, coder.log
attempt:2 has last_failure/last_cause/this_fix/rationale).
- fixtures/devloop-mock/devloop-tester.json — sequence: fail (1/3
failure with hint pointing at missing body validation) → pass (3/3).
LoopController:
- server/src/core/devloop/loop-controller.js — adds:
* runUnit(pid, unitId, opts) — drives the L1 loop via unit-registry
FSM (pending → coding → testing → failed → coding → testing →
passed/blocked). Calls Coder then Tester per attempt; on failure,
builds a retry context summarizing prior coder.log + tester
failures and feeds it into the next Coder prompt.
* _buildRetryContext + _findLatestTesterResult helpers.
* callAgent now rewrites units.json.project_id to the real pid after
mock writes (so unit-registry validation passes).
Route:
- server/routes/devloop.js — adds POST /projects/:pid/run?only=<unitId>.
Without ?only, returns 501 (full project run lands in P3).
Acceptance:
- scripts/phase2-verify.js — 6 steps:
1. POST /projects (PM + Architect)
2. POST /run?only=todo-be returns ok=true, attempts=2, final=passed
3. FSM trail matches expected 6-step transition sequence
4. coder.log second entry has all 4 reflection fields
5. runs/ has ≥2 result.json with first.ok=false, last.ok=true
6. sandbox cross-unit + prd writes blocked by ToolSandboxError
Tests (5 new cases):
- server/tests/devloop/run-unit.test.js (4) — FSM trail, coder.log
reflection, runs/ result chain, sandbox enforcement.
- server/tests/devloop/routes.test.js (+3) — POST /run validation,
full P2 loop, 404 on unknown unit.
Results:
- phase0-verify: 8/8
- phase0-test-call: 7/7
- phase1-verify: 7/7
- phase2-verify: 6/6
- vitest devloop+acp-core: 181/181 (was 174 → +7)
Specs:
- docs/prd/newfunction/_spec/plan-migration.md · P2 验收
- docs/prd/newfunction/_spec/loops-l1.md (retryWithReflection)
- docs/prd/newfunction/_spec/agents-coder.md
- docs/prd/newfunction/_spec/agents-tester.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
11 changed files
with
686 additions
and
17 deletions
fixtures/devloop-mock/devloop-architect.json
| @@ -46,7 +46,7 @@ | @@ -46,7 +46,7 @@ | ||
| 46 | }, | 46 | }, |
| 47 | { | 47 | { |
| 48 | "path": "units.json", | 48 | "path": "units.json", |
| 49 | - "content": "{\n \"rev\": 1,\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"units\": [\n {\n \"id\": \"todo-db\",\n \"kind\": \"db\",\n \"description\": \"SQLite schema for todos table\",\n \"depends_on\": [],\n \"contract_hash\": \"sha256:0000000000000001\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n },\n {\n \"id\": \"todo-be\",\n \"kind\": \"be\",\n \"description\": \"Express API for /todos\",\n \"depends_on\": [\"todo-db\"],\n \"contract_hash\": \"sha256:0000000000000002\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n },\n {\n \"id\": \"todo-fe\",\n \"kind\": \"fe\",\n \"description\": \"Vue list page\",\n \"depends_on\": [\"todo-be\"],\n \"contract_hash\": \"sha256:0000000000000003\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n }\n ]\n}\n" | 49 | + "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 {\n \"id\": \"todo-db\",\n \"type\": \"db\",\n \"kind\": \"db\",\n \"description\": \"SQLite schema for todos table\",\n \"depends_on\": [],\n \"contract_hash\": \"sha256:0000000000000001\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n },\n {\n \"id\": \"todo-be\",\n \"type\": \"be\",\n \"kind\": \"be\",\n \"description\": \"Express API for /todos\",\n \"depends_on\": [\"todo-db\"],\n \"contract_hash\": \"sha256:0000000000000002\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n },\n {\n \"id\": \"todo-fe\",\n \"type\": \"fe\",\n \"kind\": \"fe\",\n \"description\": \"Vue list page\",\n \"depends_on\": [\"todo-be\"],\n \"contract_hash\": \"sha256:0000000000000003\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n }\n ]\n}\n" |
| 50 | }, | 50 | }, |
| 51 | { | 51 | { |
| 52 | "path": "integration/spec.json", | 52 | "path": "integration/spec.json", |
| @@ -57,6 +57,18 @@ | @@ -57,6 +57,18 @@ | ||
| 57 | "content": "<!doctype html><html><head><title>SDD rev 1 snapshot</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>Snapshot</h3><p>Archive of SDD rev 1, see sdd/ for live version.</p></section></article></body></html>" | 57 | "content": "<!doctype html><html><head><title>SDD rev 1 snapshot</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>Snapshot</h3><p>Archive of SDD rev 1, see sdd/ for live version.</p></section></article></body></html>" |
| 58 | }, | 58 | }, |
| 59 | { | 59 | { |
| 60 | + "path": "units/todo-be/spec.json", | ||
| 61 | + "content": "{\n \"unit_id\": \"todo-be\",\n \"kind\": \"be\",\n \"depends_on\": [\"todo-db\"],\n \"description\": \"Express API for /todos\",\n \"acceptance_criteria\": [\n \"POST /todos with text=foo returns 201 with id, text=foo, done=false\",\n \"GET /todos returns array of todos\",\n \"POST /todos with empty body returns 400\"\n ],\n \"contract_hint\": {\n \"exports\": {\n \"http\": [\n { \"method\": \"POST\", \"path\": \"/todos\", \"responses\": { \"201\": \"Todo\", \"400\": \"BadRequest\" } },\n { \"method\": \"GET\", \"path\": \"/todos\", \"responses\": { \"200\": \"Todo[]\" } }\n ]\n }\n }\n}\n" | ||
| 62 | + }, | ||
| 63 | + { | ||
| 64 | + "path": "units/todo-db/spec.json", | ||
| 65 | + "content": "{\n \"unit_id\": \"todo-db\",\n \"kind\": \"db\",\n \"depends_on\": [],\n \"description\": \"SQLite schema for todos\",\n \"acceptance_criteria\": [\"schema.sql defines todos table with id/text/done/created_at\"]\n}\n" | ||
| 66 | + }, | ||
| 67 | + { | ||
| 68 | + "path": "units/todo-fe/spec.json", | ||
| 69 | + "content": "{\n \"unit_id\": \"todo-fe\",\n \"kind\": \"fe\",\n \"depends_on\": [\"todo-be\"],\n \"description\": \"Vue list page\",\n \"acceptance_criteria\": [\"renders list from GET /todos\", \"calls POST /todos on submit\"]\n}\n" | ||
| 70 | + }, | ||
| 71 | + { | ||
| 60 | "path": "agent-state/architect.json", | 72 | "path": "agent-state/architect.json", |
| 61 | "content": "{\"agent\":\"devloop-architect\",\"status\":\"idle\",\"current_step\":6,\"total_steps\":6,\"last_call_id\":\"call-mock-architect\",\"updated_at\":\"2026-05-13T10:01:01.000Z\"}" | 73 | "content": "{\"agent\":\"devloop-architect\",\"status\":\"idle\",\"current_step\":6,\"total_steps\":6,\"last_call_id\":\"call-mock-architect\",\"updated_at\":\"2026-05-13T10:01:01.000Z\"}" |
| 62 | }, | 74 | }, |
fixtures/devloop-mock/devloop-coder.json
0 → 100644
| 1 | +{ | ||
| 2 | + "_doc": "Mock fixture for Coder Agent (todo-be unit). sequence[0]=implement (first try, intentionally buggy in retry demo), sequence[1]=retry (reflective fix).", | ||
| 3 | + "sequence": [ | ||
| 4 | + { | ||
| 5 | + "_variant": "implement", | ||
| 6 | + "chunks": [ | ||
| 7 | + "## step 0 · 读上下文\n", | ||
| 8 | + "已读 spec.json acceptance_criteria · 3 条\n", | ||
| 9 | + "\n## step 1-5 · write code/ + contract.json\n", | ||
| 10 | + "POST /todos · GET /todos\n", | ||
| 11 | + "\n## step 6 · append coder.log\n", | ||
| 12 | + "implement 首次实现完成\n" | ||
| 13 | + ], | ||
| 14 | + "result": "mock-ok devloop-coder[implement] todo-be: code/ + contract.json + coder.log", | ||
| 15 | + "files": [ | ||
| 16 | + { | ||
| 17 | + "path": "units/todo-be/code/server.js", | ||
| 18 | + "content": "// mock todo-be · attempt 1 (intentionally missing 400 branch for retry demo)\nconst express = require('express');\nconst app = express();\napp.use(express.json());\nconst todos = [];\nlet nextId = 1;\napp.post('/todos', (req, res) => {\n // BUG: 没校验 text → empty body 会进 todos\n const t = { id: nextId++, text: req.body.text, done: false, created_at: new Date().toISOString() };\n todos.push(t);\n res.status(201).json(t);\n});\napp.get('/todos', (_req, res) => res.json(todos));\nmodule.exports = app;\n" | ||
| 19 | + }, | ||
| 20 | + { | ||
| 21 | + "path": "units/todo-be/contract.json", | ||
| 22 | + "content": "{\n \"unit_id\": \"todo-be\",\n \"version\": \"sha256:0000000000000002\",\n \"exports\": {\n \"http\": [\n { \"method\": \"POST\", \"path\": \"/todos\", \"responses\": { \"201\": \"Todo\", \"400\": \"BadRequest\" } },\n { \"method\": \"GET\", \"path\": \"/todos\", \"responses\": { \"200\": \"Todo[]\" } }\n ]\n }\n}\n" | ||
| 23 | + }, | ||
| 24 | + { | ||
| 25 | + "path": "units/todo-be/coder.log", | ||
| 26 | + "content": "---\nattempt: 1\nts: 2026-05-13T10:02:00.000Z\nthis_fix: implement POST/GET /todos with in-memory array\nrationale: minimal happy-path scaffold per spec acceptance_criteria #1 and #2\nchanged_files: [\"code/server.js\", \"contract.json\"]\n---\n\n" | ||
| 27 | + } | ||
| 28 | + ] | ||
| 29 | + }, | ||
| 30 | + { | ||
| 31 | + "_variant": "retry", | ||
| 32 | + "chunks": [ | ||
| 33 | + "## step 0 · 读 failures + hint + 自己 coder.log\n", | ||
| 34 | + "上次失败:POST /todos with empty body 返 201 而期望 400\n", | ||
| 35 | + "\n## step 1 · 反思\n", | ||
| 36 | + "上次错: 没校验 req.body.text\n", | ||
| 37 | + "这次改: 在 handler 入口 if (!req.body?.text) return res.status(400)\n", | ||
| 38 | + "因为: 400 是参数错语义,201 表示创建成功\n", | ||
| 39 | + "\n## step 2-5 · 精准 patch + append coder.log\n", | ||
| 40 | + "改了一行,重写 coder.log retry 模板\n" | ||
| 41 | + ], | ||
| 42 | + "result": "mock-ok devloop-coder[retry] todo-be: added body validation; 400 branch covered", | ||
| 43 | + "files": [ | ||
| 44 | + { | ||
| 45 | + "path": "units/todo-be/code/server.js", | ||
| 46 | + "content": "// mock todo-be · attempt 2 (with body validation per retry reflection)\nconst express = require('express');\nconst app = express();\napp.use(express.json());\nconst todos = [];\nlet nextId = 1;\napp.post('/todos', (req, res) => {\n if (!req.body?.text) return res.status(400).json({ error: 'missing field: text' });\n const t = { id: nextId++, text: req.body.text, done: false, created_at: new Date().toISOString() };\n todos.push(t);\n res.status(201).json(t);\n});\napp.get('/todos', (_req, res) => res.json(todos));\nmodule.exports = app;\n" | ||
| 47 | + }, | ||
| 48 | + { | ||
| 49 | + "path": "units/todo-be/coder.log", | ||
| 50 | + "content": "---\nattempt: 1\nts: 2026-05-13T10:02:00.000Z\nthis_fix: implement POST/GET /todos with in-memory array\nrationale: minimal happy-path scaffold per spec acceptance_criteria #1 and #2\nchanged_files: [\"code/server.js\", \"contract.json\"]\n---\n\n---\nattempt: 2\nts: 2026-05-13T10:03:00.000Z\nlast_failure: POST /todos with empty body returned 201, expected 400\nlast_cause: req.body.text 未校验,空 body 也通过进了 todos\nthis_fix: 在 POST handler 入口加 if (!req.body?.text) return 400\nrationale: 400 是参数错语义;201 表示创建成功。区分 4xx 和 2xx 才符合 contract.\nchanged_files: [\"code/server.js\"]\n---\n\n" | ||
| 51 | + } | ||
| 52 | + ] | ||
| 53 | + } | ||
| 54 | + ] | ||
| 55 | +} |
fixtures/devloop-mock/devloop-tester.json
0 → 100644
| 1 | +{ | ||
| 2 | + "_doc": "Mock fixture for Tester Agent (todo-be unit). sequence[0]=fail (first run after implement) with hint pointing at missing 400 branch; sequence[1]=pass (after Coder retry).", | ||
| 3 | + "sequence": [ | ||
| 4 | + { | ||
| 5 | + "_variant": "fail", | ||
| 6 | + "chunks": [ | ||
| 7 | + "## step 0 · 读 spec.json + contract.json + code/server.js\n", | ||
| 8 | + "\n## step 1-2 · 写 tests/todo-be.spec.js,覆盖 3 acceptance + 2 status code\n", | ||
| 9 | + "\n## step 3 · 跑 vitest\n", | ||
| 10 | + "1 of 3 failed\n", | ||
| 11 | + "\n## step 4 · parse_vitest + hint\n", | ||
| 12 | + "失败: POST /todos with empty body 返 201, 期望 400\n", | ||
| 13 | + "\n## step 5 · bug_open\n" | ||
| 14 | + ], | ||
| 15 | + "result": "mock-ok devloop-tester[fail] todo-be: 2/3 pass · failure has hint", | ||
| 16 | + "files": [ | ||
| 17 | + { | ||
| 18 | + "path": "units/todo-be/tests/todo-be.spec.js", | ||
| 19 | + "content": "// mock tests (P2 fixture)\nimport request from 'supertest';\nimport { describe, it, expect } from 'vitest';\nimport app from '../code/server.js';\ndescribe('todo-be', () => {\n it('POST /todos with text returns 201', async () => {\n const r = await request(app).post('/todos').send({ text: 'buy milk' });\n expect(r.status).toBe(201);\n });\n it('GET /todos returns array', async () => {\n const r = await request(app).get('/todos');\n expect(r.status).toBe(200);\n expect(Array.isArray(r.body)).toBe(true);\n });\n it('POST /todos with empty body returns 400', async () => {\n const r = await request(app).post('/todos').send({});\n expect(r.status).toBe(400);\n });\n});\n" | ||
| 20 | + }, | ||
| 21 | + { | ||
| 22 | + "path": "units/todo-be/runs/run-mock-attempt-1/result.json", | ||
| 23 | + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"unit_id\": \"todo-be\",\n \"run_id\": \"run-mock-attempt-1\",\n \"summary\": { \"total\": 3, \"passed\": 2, \"failed\": 1, \"skipped\": 0 },\n \"failures\": [\n {\n \"test\": \"POST /todos with empty body returns 400\",\n \"expected\": 400,\n \"got\": 201,\n \"stack\": \"AssertionError: expected 201 to be 400\",\n \"hint\": \"req.body.text 未校验;在 POST handler 入口加存在性判断,空 body 返 400 而不是 201\"\n }\n ],\n \"duration_ms\": 12\n}\n" | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + }, | ||
| 27 | + { | ||
| 28 | + "_variant": "pass", | ||
| 29 | + "chunks": [ | ||
| 30 | + "## step 0 · 读 spec.json + contract.json + code/server.js (已被 Coder 改动)\n", | ||
| 31 | + "\n## step 1 · 跑现有 tests (复用上一轮 spec)\n", | ||
| 32 | + "\n## step 2 · parse_vitest\n", | ||
| 33 | + "3 of 3 passed\n", | ||
| 34 | + "\n## step 3 · bug_transition fixed\n" | ||
| 35 | + ], | ||
| 36 | + "result": "mock-ok devloop-tester[pass] todo-be: 3/3 pass · L1 green", | ||
| 37 | + "files": [ | ||
| 38 | + { | ||
| 39 | + "path": "units/todo-be/runs/run-mock-attempt-2/result.json", | ||
| 40 | + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"unit_id\": \"todo-be\",\n \"run_id\": \"run-mock-attempt-2\",\n \"summary\": { \"total\": 3, \"passed\": 3, \"failed\": 0, \"skipped\": 0 },\n \"failures\": [],\n \"duration_ms\": 11\n}\n" | ||
| 41 | + } | ||
| 42 | + ] | ||
| 43 | + } | ||
| 44 | + ] | ||
| 45 | +} |
packages/acp-core/protocol/ClaudeCliProtocol.js
| @@ -524,38 +524,51 @@ class ClaudeCliProtocol extends EventEmitter { | @@ -524,38 +524,51 @@ class ClaudeCliProtocol extends EventEmitter { | ||
| 524 | /** | 524 | /** |
| 525 | * Mock 模式 sendPrompt 实现: | 525 | * Mock 模式 sendPrompt 实现: |
| 526 | * 1. 找 fixture: {mockFixtureDir}/{agentName}.json (若无,用 default.json,再无 → 走 echo fallback) | 526 | * 1. 找 fixture: {mockFixtureDir}/{agentName}.json (若无,用 default.json,再无 → 走 echo fallback) |
| 527 | - * 2. fixture 形如:{ chunks: ["...text...", ...], result: "summary text" } | ||
| 528 | - * 3. 同步 emit session_update (agent_message_chunk) + acp_event session/result | 527 | + * 2. fixture 两种形态: |
| 528 | + * (a) 单次: { chunks, result, files } | ||
| 529 | + * (b) 多次: { sequence: [ {chunks,result,files}, ... ] } | ||
| 530 | + * 每次调用消耗一个 variant;耗尽后 stick on 最后一个。 | ||
| 531 | + * 3. 同步 emit session_update (agent_message_chunk) + mock/files + session/result | ||
| 529 | * 4. resolve | 532 | * 4. resolve |
| 530 | * | 533 | * |
| 531 | - * 不发起任何子进程,不依赖外部网络;用于 phase0 测试。 | 534 | + * 不发起任何子进程,不依赖外部网络;用于 phase0/P1/P2 测试。 |
| 532 | */ | 535 | */ |
| 533 | async _sendMockPrompt(text) { | 536 | async _sendMockPrompt(text) { |
| 534 | const agentName = this.agentInfo?.name || this.options.agentName || 'default'; | 537 | const agentName = this.agentInfo?.name || this.options.agentName || 'default'; |
| 535 | const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`); | 538 | const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`); |
| 536 | const defaultPath = path.join(this._mockFixtureDir, 'default.json'); | 539 | const defaultPath = path.join(this._mockFixtureDir, 'default.json'); |
| 537 | 540 | ||
| 538 | - let fixture; | 541 | + let raw; |
| 539 | if (fs.existsSync(fixturePath)) { | 542 | if (fs.existsSync(fixturePath)) { |
| 540 | try { | 543 | try { |
| 541 | - fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); | 544 | + raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); |
| 542 | } catch (e) { | 545 | } catch (e) { |
| 543 | console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`); | 546 | console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`); |
| 544 | } | 547 | } |
| 545 | } | 548 | } |
| 546 | - if (!fixture && fs.existsSync(defaultPath)) { | 549 | + if (!raw && fs.existsSync(defaultPath)) { |
| 547 | try { | 550 | try { |
| 548 | - fixture = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); | 551 | + raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); |
| 549 | } catch { /* ignore */ } | 552 | } catch { /* ignore */ } |
| 550 | } | 553 | } |
| 551 | - if (!fixture) { | 554 | + if (!raw) { |
| 552 | // echo fallback: 把 prompt 头 80 字符回显 | 555 | // echo fallback: 把 prompt 头 80 字符回显 |
| 553 | - fixture = { | 556 | + raw = { |
| 554 | chunks: [`[mock] received prompt for ${agentName}: `, text.slice(0, 80)], | 557 | chunks: [`[mock] received prompt for ${agentName}: `, text.slice(0, 80)], |
| 555 | result: `mock-ok (no fixture for ${agentName})`, | 558 | result: `mock-ok (no fixture for ${agentName})`, |
| 556 | }; | 559 | }; |
| 557 | } | 560 | } |
| 558 | 561 | ||
| 562 | + // 选 variant:有 sequence → 按调用计数取;否则就是 raw 本身 | ||
| 563 | + let fixture = raw; | ||
| 564 | + if (Array.isArray(raw.sequence) && raw.sequence.length > 0) { | ||
| 565 | + const key = `${this._mockFixtureDir}:${agentName}`; | ||
| 566 | + const idx = ClaudeCliProtocol._mockCallCount.get(key) || 0; | ||
| 567 | + const pick = Math.min(idx, raw.sequence.length - 1); | ||
| 568 | + fixture = raw.sequence[pick] || raw.sequence[0]; | ||
| 569 | + ClaudeCliProtocol._mockCallCount.set(key, idx + 1); | ||
| 570 | + } | ||
| 571 | + | ||
| 559 | this.sessionId = `claude-cli-mock-${Date.now()}`; | 572 | this.sessionId = `claude-cli-mock-${Date.now()}`; |
| 560 | 573 | ||
| 561 | // 发 chunks(同步触发 session_update,模拟 stream_event delta) | 574 | // 发 chunks(同步触发 session_update,模拟 stream_event delta) |
| @@ -605,4 +618,14 @@ class ClaudeCliProtocol extends EventEmitter { | @@ -605,4 +618,14 @@ class ClaudeCliProtocol extends EventEmitter { | ||
| 605 | } | 618 | } |
| 606 | } | 619 | } |
| 607 | 620 | ||
| 621 | +// Mock 模式下的"调用次数"全局计数 (跨实例共享,因为同个 fixture 会被多个 | ||
| 622 | +// protocol 实例消费,例如 LoopController 每次 callAgent 都 new 一个 protocol)。 | ||
| 623 | +// key = `${mockFixtureDir}:${agentName}`,value = 已用过的 variant 数量。 | ||
| 624 | +ClaudeCliProtocol._mockCallCount = new Map(); | ||
| 625 | + | ||
| 626 | +// 测试 / runUnit 跑前可调用清零,确保 sequence 重新从 0 开始 | ||
| 627 | +ClaudeCliProtocol.resetMockState = function () { | ||
| 628 | + ClaudeCliProtocol._mockCallCount.clear(); | ||
| 629 | +}; | ||
| 630 | + | ||
| 608 | module.exports = { ClaudeCliProtocol }; | 631 | module.exports = { ClaudeCliProtocol }; |
scripts/phase1-verify.js
| @@ -122,14 +122,16 @@ async function main() { | @@ -122,14 +122,16 @@ async function main() { | ||
| 122 | return { detail: `${required.length}/${required.length} files present` }; | 122 | return { detail: `${required.length}/${required.length} files present` }; |
| 123 | }); | 123 | }); |
| 124 | 124 | ||
| 125 | - await step('units.json has 3 units with l2_rounds/l2_budget', () => { | 125 | + await step('units.json has 3 units + project-level l2_rounds/l2_budget', () => { |
| 126 | const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | 126 | const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); |
| 127 | if (!Array.isArray(u.units) || u.units.length !== 3) { | 127 | if (!Array.isArray(u.units) || u.units.length !== 3) { |
| 128 | throw new Error('want 3 units, got: ' + (u.units?.length || 0)); | 128 | throw new Error('want 3 units, got: ' + (u.units?.length || 0)); |
| 129 | } | 129 | } |
| 130 | + if (typeof u.l2_rounds !== 'number') throw new Error('l2_rounds missing'); | ||
| 131 | + if (typeof u.l2_budget !== 'number') throw new Error('l2_budget missing'); | ||
| 130 | for (const x of u.units) { | 132 | for (const x of u.units) { |
| 131 | - if (typeof x.l2_rounds !== 'number') throw new Error(`unit ${x.id} missing l2_rounds`); | ||
| 132 | - if (typeof x.l2_budget !== 'number') throw new Error(`unit ${x.id} missing l2_budget`); | 133 | + if (x.status !== 'pending') throw new Error(`unit ${x.id} status=${x.status}, want pending`); |
| 134 | + if (typeof x.retry_budget !== 'number') throw new Error(`unit ${x.id} missing retry_budget`); | ||
| 133 | } | 135 | } |
| 134 | return { detail: '3 units · l2 fields present · DAG: ' + u.units.map(x => x.id).join(' → ') }; | 136 | return { detail: '3 units · l2 fields present · DAG: ' + u.units.map(x => x.id).join(' → ') }; |
| 135 | }); | 137 | }); |
scripts/phase2-verify.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * scripts/phase2-verify.js | ||
| 4 | + * | ||
| 5 | + * P2 L1 单 Unit 验收 (mock 模式): | ||
| 6 | + * 1. 启动 devloop router | ||
| 7 | + * 2. POST /api/devloop/projects 创建 todo-demo | ||
| 8 | + * 3. POST /api/devloop/projects/:pid/run?only=todo-be 跑 L1 loop | ||
| 9 | + * 4. 校验 state 链路: pending → coding → testing → failed → coding → testing → passed | ||
| 10 | + * 5. 校验 coder.log 第二条决策含 last_failure (反思生效) | ||
| 11 | + * 6. 校验 runs/ 下至少 2 个 result.json,首失败末通过 | ||
| 12 | + * 7. 校验沙箱越权被 _sandbox.assertWritable 拒绝 | ||
| 13 | + * | ||
| 14 | + * 通过 → exit 0;失败 → exit 1 | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | ||
| 18 | + | ||
| 19 | +const fs = require('fs'); | ||
| 20 | +const path = require('path'); | ||
| 21 | + | ||
| 22 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | ||
| 23 | +require('module').globalPaths.push(serverNodeModules); | ||
| 24 | +const express = (() => { | ||
| 25 | + try { return require('express'); } | ||
| 26 | + catch { | ||
| 27 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | ||
| 28 | + } | ||
| 29 | +})(); | ||
| 30 | + | ||
| 31 | +const pathResolver = require('../server/services/devloop/path-resolver'); | ||
| 32 | +const unitRegistry = require('../server/services/devloop/unit-registry'); | ||
| 33 | +const devloopRouter = require('../server/routes/devloop'); | ||
| 34 | +const { assertWritable } = require('../server/src/tools/devloop/_sandbox'); | ||
| 35 | +const { ToolSandboxError } = require('../server/src/tools/devloop/_errors'); | ||
| 36 | + | ||
| 37 | +const results = []; | ||
| 38 | +function step(name, fn) { | ||
| 39 | + return (async () => { | ||
| 40 | + try { | ||
| 41 | + const r = await fn(); | ||
| 42 | + results.push({ name, ok: true, detail: r?.detail || '' }); | ||
| 43 | + return r; | ||
| 44 | + } catch (e) { | ||
| 45 | + results.push({ name, ok: false, reason: e.message }); | ||
| 46 | + throw e; | ||
| 47 | + } | ||
| 48 | + })(); | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +async function startApp() { | ||
| 52 | + const app = express(); | ||
| 53 | + app.use(express.json()); | ||
| 54 | + app.use('/api/devloop', devloopRouter); | ||
| 55 | + return new Promise((resolve) => { | ||
| 56 | + const srv = app.listen(0, '127.0.0.1', () => { | ||
| 57 | + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); | ||
| 58 | + }); | ||
| 59 | + }); | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +async function main() { | ||
| 63 | + const { srv, base } = await startApp(); | ||
| 64 | + const cid = 'c-phase2-' + Date.now().toString(36); | ||
| 65 | + let pid; | ||
| 66 | + const fsmTrail = []; | ||
| 67 | + | ||
| 68 | + try { | ||
| 69 | + // 准备:跑 PM + Architect 把 units.json 写好 | ||
| 70 | + const r0 = await step('POST /projects (PM + Architect)', async () => { | ||
| 71 | + const resp = await fetch(`${base}/api/devloop/projects`, { | ||
| 72 | + method: 'POST', | ||
| 73 | + headers: { 'Content-Type': 'application/json' }, | ||
| 74 | + body: JSON.stringify({ | ||
| 75 | + company_id: cid, | ||
| 76 | + name: 'todo-demo', | ||
| 77 | + requirement: '做一个 TODO 列表 API + Vue 前端', | ||
| 78 | + }), | ||
| 79 | + }); | ||
| 80 | + if (resp.status !== 200) throw new Error('status ' + resp.status); | ||
| 81 | + const j = await resp.json(); | ||
| 82 | + pid = j.pid; | ||
| 83 | + return { detail: `pid=${pid}` }; | ||
| 84 | + }); | ||
| 85 | + | ||
| 86 | + // 监听 unit-registry SSE 收 FSM 转换 | ||
| 87 | + unitRegistry.attachSseBroker({ | ||
| 88 | + emit: (_pid, kind, ev) => { | ||
| 89 | + if (kind === 'unit.state' && ev.unit_id === 'todo-be') { | ||
| 90 | + fsmTrail.push(`${ev.from}→${ev.to}`); | ||
| 91 | + } | ||
| 92 | + }, | ||
| 93 | + }); | ||
| 94 | + | ||
| 95 | + await step('POST /run?only=todo-be (L1 loop)', async () => { | ||
| 96 | + const resp = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' }); | ||
| 97 | + if (resp.status !== 200) throw new Error('status ' + resp.status); | ||
| 98 | + const j = await resp.json(); | ||
| 99 | + if (!j.ok) throw new Error('runUnit ok=false'); | ||
| 100 | + if (j.attempts !== 2) throw new Error('want 2 attempts, got ' + j.attempts); | ||
| 101 | + if (j.final_state !== 'passed') throw new Error('final_state=' + j.final_state); | ||
| 102 | + return { detail: `attempts=2 · final=passed · history=${j.history.length} steps` }; | ||
| 103 | + }); | ||
| 104 | + | ||
| 105 | + await step('FSM trail: pending → coding → testing → failed → coding → testing → passed', () => { | ||
| 106 | + const expected = [ | ||
| 107 | + 'pending→coding', | ||
| 108 | + 'coding→testing', | ||
| 109 | + 'testing→failed', | ||
| 110 | + 'failed→coding', | ||
| 111 | + 'coding→testing', | ||
| 112 | + 'testing→passed', | ||
| 113 | + ]; | ||
| 114 | + if (JSON.stringify(fsmTrail) !== JSON.stringify(expected)) { | ||
| 115 | + throw new Error('got: ' + fsmTrail.join(', ')); | ||
| 116 | + } | ||
| 117 | + return { detail: fsmTrail.join(' · ') }; | ||
| 118 | + }); | ||
| 119 | + | ||
| 120 | + await step('coder.log second entry references previous failure', () => { | ||
| 121 | + const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log'); | ||
| 122 | + const log = fs.readFileSync(logPath, 'utf8'); | ||
| 123 | + if (!/attempt: 1/.test(log)) throw new Error('missing attempt: 1'); | ||
| 124 | + if (!/attempt: 2/.test(log)) throw new Error('missing attempt: 2'); | ||
| 125 | + const att2 = log.split('attempt: 2')[1] || ''; | ||
| 126 | + for (const field of ['last_failure', 'last_cause', 'this_fix', 'rationale']) { | ||
| 127 | + if (!att2.includes(field)) throw new Error('attempt:2 missing ' + field); | ||
| 128 | + } | ||
| 129 | + return { detail: 'reflection template (last_failure/last_cause/this_fix/rationale) present' }; | ||
| 130 | + }); | ||
| 131 | + | ||
| 132 | + await step('runs/ has ≥2 result.json with first.ok=false, last.ok=true', () => { | ||
| 133 | + const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs'); | ||
| 134 | + const ids = fs.readdirSync(runsDir).sort(); | ||
| 135 | + if (ids.length < 2) throw new Error('want ≥2 runs, got ' + ids.length); | ||
| 136 | + const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8')); | ||
| 137 | + const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8')); | ||
| 138 | + if (first.ok !== false) throw new Error('first run.ok should be false'); | ||
| 139 | + if (last.ok !== true) throw new Error('last run.ok should be true'); | ||
| 140 | + return { detail: `${ids.length} runs · first.ok=false · last.ok=true` }; | ||
| 141 | + }); | ||
| 142 | + | ||
| 143 | + await step('sandbox: writing other unit / prd is rejected', () => { | ||
| 144 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 145 | + const allowList = ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log']; | ||
| 146 | + const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' }; | ||
| 147 | + // allowed | ||
| 148 | + assertWritable(path.join(root, 'units/todo-be/code/server.js'), allowList, ctx); | ||
| 149 | + // denied (cross-unit) | ||
| 150 | + let blockedCross = false; | ||
| 151 | + try { assertWritable(path.join(root, 'units/todo-fe/code/foo.js'), allowList, ctx); } | ||
| 152 | + catch (e) { blockedCross = e instanceof ToolSandboxError; } | ||
| 153 | + if (!blockedCross) throw new Error('cross-unit write was NOT blocked'); | ||
| 154 | + // denied (prd) | ||
| 155 | + let blockedPrd = false; | ||
| 156 | + try { assertWritable(path.join(root, 'prd/index.html'), allowList, ctx); } | ||
| 157 | + catch (e) { blockedPrd = e instanceof ToolSandboxError; } | ||
| 158 | + if (!blockedPrd) throw new Error('prd write was NOT blocked'); | ||
| 159 | + return { detail: 'cross-unit + prd writes blocked by ToolSandboxError' }; | ||
| 160 | + }); | ||
| 161 | + | ||
| 162 | + } finally { | ||
| 163 | + srv.close(); | ||
| 164 | + try { | ||
| 165 | + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); | ||
| 166 | + } catch { /* ignore */ } | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + let pass = 0, fail = 0; | ||
| 170 | + console.log('\n┌─ DevLoop Phase 2 — L1 Single Unit Acceptance ────'); | ||
| 171 | + for (const r of results) { | ||
| 172 | + const tag = r.ok ? '✓' : '✗'; | ||
| 173 | + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`); | ||
| 174 | + r.ok ? pass++ : fail++; | ||
| 175 | + } | ||
| 176 | + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); | ||
| 177 | + process.exit(fail ? 1 : 0); | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +main().catch(e => { | ||
| 181 | + console.error('\n[phase2-verify] fatal:', e); | ||
| 182 | + for (const r of results) { | ||
| 183 | + console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); | ||
| 184 | + } | ||
| 185 | + process.exit(1); | ||
| 186 | +}); |
server/routes/devloop.js
| @@ -76,6 +76,38 @@ router.get('/projects', h(async (_req, res) => { | @@ -76,6 +76,38 @@ router.get('/projects', h(async (_req, res) => { | ||
| 76 | res.json({ projects }); | 76 | res.json({ projects }); |
| 77 | })); | 77 | })); |
| 78 | 78 | ||
| 79 | +// POST /api/devloop/projects/:pid/run?only=<unitId> | ||
| 80 | +// P2: only=<unitId> 跑单 unit;不带 only → 501 (P3 才补全 runProject) | ||
| 81 | +router.post('/projects/:pid/run', h(async (req, res) => { | ||
| 82 | + const { pid } = req.params; | ||
| 83 | + 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 | + 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, | ||
| 93 | + 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 | + } catch (e) { | ||
| 101 | + if (/Unknown project/.test(e.message)) { | ||
| 102 | + return res.status(404).json({ error: 'unknown project: ' + pid }); | ||
| 103 | + } | ||
| 104 | + if (/not found in project/.test(e.message)) { | ||
| 105 | + return res.status(404).json({ error: 'unknown unit: ' + only }); | ||
| 106 | + } | ||
| 107 | + throw e; | ||
| 108 | + } | ||
| 109 | +})); | ||
| 110 | + | ||
| 79 | // GET /api/devloop/projects/:pid/state | 111 | // GET /api/devloop/projects/:pid/state |
| 80 | router.get('/projects/:pid/state', h(async (req, res) => { | 112 | router.get('/projects/:pid/state', h(async (req, res) => { |
| 81 | try { | 113 | try { |
server/src/core/devloop/loop-controller.js
| @@ -23,6 +23,7 @@ const path = require('path'); | @@ -23,6 +23,7 @@ const path = require('path'); | ||
| 23 | const pathResolver = require('../../../services/devloop/path-resolver'); | 23 | const pathResolver = require('../../../services/devloop/path-resolver'); |
| 24 | const gitVault = require('../../../services/devloop/git-vault'); | 24 | const gitVault = require('../../../services/devloop/git-vault'); |
| 25 | const timelineLogger = require('../../../services/devloop/timeline-logger'); | 25 | const timelineLogger = require('../../../services/devloop/timeline-logger'); |
| 26 | +const unitRegistry = require('../../../services/devloop/unit-registry'); | ||
| 26 | const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); | 27 | const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); |
| 27 | 28 | ||
| 28 | const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); | 29 | const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); |
| @@ -191,7 +192,16 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) { | @@ -191,7 +192,16 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) { | ||
| 191 | } | 192 | } |
| 192 | const abs = path.join(projectRoot, f.path); | 193 | const abs = path.join(projectRoot, f.path); |
| 193 | fs.mkdirSync(path.dirname(abs), { recursive: true }); | 194 | fs.mkdirSync(path.dirname(abs), { recursive: true }); |
| 194 | - fs.writeFileSync(abs, f.content); | 195 | + let content = f.content; |
| 196 | + // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_") | ||
| 197 | + if (f.path === 'units.json' || f.path.endsWith('/units.json')) { | ||
| 198 | + try { | ||
| 199 | + const u = JSON.parse(content); | ||
| 200 | + u.project_id = pid; | ||
| 201 | + content = JSON.stringify(u, null, 2); | ||
| 202 | + } catch { /* leave as-is, downstream will fail validation */ } | ||
| 203 | + } | ||
| 204 | + fs.writeFileSync(abs, content); | ||
| 195 | filesWritten.push(f.path); | 205 | filesWritten.push(f.path); |
| 196 | } | 206 | } |
| 197 | 207 | ||
| @@ -317,10 +327,148 @@ function getProjectState(pid) { | @@ -317,10 +327,148 @@ function getProjectState(pid) { | ||
| 317 | return out; | 327 | return out; |
| 318 | } | 328 | } |
| 319 | 329 | ||
| 330 | +/** | ||
| 331 | + * runUnit — L1 单 Unit 循环 (P2)。 | ||
| 332 | + * | ||
| 333 | + * 串行 Coder(implement) → Tester(test)。失败时 retryWithReflection 把 | ||
| 334 | + * 上一次 failure + coder.log 摘要打回去重跑 Coder + Tester,直到通过或耗 | ||
| 335 | + * 尽 retry_budget。每个状态转换走 unit-registry FSM。 | ||
| 336 | + * | ||
| 337 | + * @param {string} pid | ||
| 338 | + * @param {string} unitId | ||
| 339 | + * @param {object} opts | ||
| 340 | + * - mock?: boolean (默认 true,P2 阶段) | ||
| 341 | + * - onChunk?: (agent, text) => void | ||
| 342 | + * - resetMockSequence?: boolean (默认 true,跑前清零 fixture sequence 计数) | ||
| 343 | + * @returns { ok, unit_id, attempts, final_state, history: [{ agent, attempt, ... }] } | ||
| 344 | + */ | ||
| 345 | +async function runUnit(pid, unitId, opts = {}) { | ||
| 346 | + if (opts.resetMockSequence !== false) { | ||
| 347 | + ClaudeCliProtocol.resetMockState(); | ||
| 348 | + } | ||
| 349 | + | ||
| 350 | + const unit = unitRegistry.get(pid, unitId); | ||
| 351 | + const budget = unit.retry_budget || 5; | ||
| 352 | + const history = []; | ||
| 353 | + let attempt = 0; | ||
| 354 | + let finalOk = false; | ||
| 355 | + | ||
| 356 | + while (attempt < budget) { | ||
| 357 | + attempt++; | ||
| 358 | + | ||
| 359 | + // ── coding ─────────────────────────────────────────────── | ||
| 360 | + unitRegistry.markCoding(pid, unitId); | ||
| 361 | + const coderHistorySummary = _buildRetryContext(history); | ||
| 362 | + const coderPrompt = attempt === 1 | ||
| 363 | + ? `Mode: implement\nUnit: ${unitId}\n\nRead units/${unitId}/spec.json and produce code/ + contract.json + coder.log.` | ||
| 364 | + : `Mode: retry (attempt ${attempt})\nUnit: ${unitId}\n\n${coderHistorySummary}\n\nReflect on the failure and patch code/ minimally; append a new coder.log entry with last_failure/last_cause/this_fix/rationale fields.`; | ||
| 365 | + const coderResult = await callAgent(pid, 'devloop-coder', coderPrompt, { | ||
| 366 | + mock: opts.mock !== false, | ||
| 367 | + onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-coder', t) : undefined, | ||
| 368 | + }); | ||
| 369 | + history.push({ | ||
| 370 | + agent: 'devloop-coder', attempt, | ||
| 371 | + files: coderResult.files_written, | ||
| 372 | + summary: coderResult.result_text, | ||
| 373 | + commit: coderResult.commit?.sha, | ||
| 374 | + }); | ||
| 375 | + | ||
| 376 | + // ── testing ────────────────────────────────────────────── | ||
| 377 | + unitRegistry.markTesting(pid, unitId); | ||
| 378 | + const testerPrompt = `Mode: ${attempt === 1 ? 'test' : 'retry'}\nUnit: ${unitId}\n\nRead units/${unitId}/code/ + spec.json, run vitest, parse the JSON reporter, and write runs/{runId}/result.json.`; | ||
| 379 | + const testerResult = await callAgent(pid, 'devloop-tester', testerPrompt, { | ||
| 380 | + mock: opts.mock !== false, | ||
| 381 | + onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-tester', t) : undefined, | ||
| 382 | + }); | ||
| 383 | + | ||
| 384 | + const resultRef = _findLatestTesterResult(pid, unitId); | ||
| 385 | + const testerOk = resultRef ? !!resultRef.parsed?.ok : false; | ||
| 386 | + history.push({ | ||
| 387 | + agent: 'devloop-tester', attempt, | ||
| 388 | + files: testerResult.files_written, | ||
| 389 | + summary: testerResult.result_text, | ||
| 390 | + commit: testerResult.commit?.sha, | ||
| 391 | + ok: testerOk, | ||
| 392 | + failures: resultRef?.parsed?.failures || [], | ||
| 393 | + }); | ||
| 394 | + | ||
| 395 | + // ── transition ────────────────────────────────────────── | ||
| 396 | + if (testerOk) { | ||
| 397 | + unitRegistry.pass(pid, unitId, unit.contract_hash); | ||
| 398 | + finalOk = true; | ||
| 399 | + break; | ||
| 400 | + } else { | ||
| 401 | + const reason = resultRef?.parsed?.failures?.[0]?.test | ||
| 402 | + || resultRef?.parsed?.failures?.[0]?.hint | ||
| 403 | + || `tester reported failure (attempt ${attempt})`; | ||
| 404 | + unitRegistry.fail(pid, unitId, reason); | ||
| 405 | + // 如果还有预算 → 再循环;否则 markBlocked | ||
| 406 | + if (attempt >= budget) { | ||
| 407 | + unitRegistry.block(pid, unitId, `retry_budget_exceeded after ${attempt} attempts`); | ||
| 408 | + break; | ||
| 409 | + } | ||
| 410 | + // 把 unit 从 failed → coding 由下一轮 markCoding 完成 (FSM 允许) | ||
| 411 | + } | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + try { | ||
| 415 | + timelineLogger.append(pid, { | ||
| 416 | + source: 'system', | ||
| 417 | + kind: 'unit.run_complete', | ||
| 418 | + ref: unitId, | ||
| 419 | + summary: `Unit ${unitId} ${finalOk ? 'passed' : 'blocked'} after ${attempt} attempt(s)`, | ||
| 420 | + payload: { ok: finalOk, attempts: attempt, budget }, | ||
| 421 | + }); | ||
| 422 | + } catch { /* swallow */ } | ||
| 423 | + | ||
| 424 | + return { | ||
| 425 | + ok: finalOk, | ||
| 426 | + unit_id: unitId, | ||
| 427 | + attempts: attempt, | ||
| 428 | + final_state: unitRegistry.get(pid, unitId).status, | ||
| 429 | + history, | ||
| 430 | + }; | ||
| 431 | +} | ||
| 432 | + | ||
| 433 | +/** 把 history 数组压成给 Coder 反思用的 markdown 摘要 */ | ||
| 434 | +function _buildRetryContext(history) { | ||
| 435 | + if (!history.length) return ''; | ||
| 436 | + const lines = ['## Retry History']; | ||
| 437 | + for (const h of history) { | ||
| 438 | + lines.push(`- attempt ${h.attempt} · ${h.agent}: ${(h.summary || '').slice(0, 120)}`); | ||
| 439 | + if (h.agent === 'devloop-tester' && Array.isArray(h.failures) && h.failures.length) { | ||
| 440 | + for (const f of h.failures.slice(0, 3)) { | ||
| 441 | + lines.push(` - failure: ${f.test || f.name || '(unnamed)'}: ${f.hint || f.stack?.slice(0, 80) || ''}`); | ||
| 442 | + } | ||
| 443 | + } | ||
| 444 | + } | ||
| 445 | + return lines.join('\n'); | ||
| 446 | +} | ||
| 447 | + | ||
| 448 | +/** | ||
| 449 | + * 找 units/{unitId}/runs/ 下最新一次 result.json (按字典序 — runId 含日期前缀)。 | ||
| 450 | + */ | ||
| 451 | +function _findLatestTesterResult(pid, unitId) { | ||
| 452 | + const runsDir = path.join(pathResolver.resolveUnitDir(pid, unitId), 'runs'); | ||
| 453 | + if (!fs.existsSync(runsDir)) return null; | ||
| 454 | + const ids = fs.readdirSync(runsDir).filter(n => fs.statSync(path.join(runsDir, n)).isDirectory()); | ||
| 455 | + if (!ids.length) return null; | ||
| 456 | + ids.sort(); | ||
| 457 | + const latest = ids[ids.length - 1]; | ||
| 458 | + const resultPath = path.join(runsDir, latest, 'result.json'); | ||
| 459 | + if (!fs.existsSync(resultPath)) return { runId: latest, parsed: null }; | ||
| 460 | + try { | ||
| 461 | + return { runId: latest, parsed: JSON.parse(fs.readFileSync(resultPath, 'utf8')) }; | ||
| 462 | + } catch { | ||
| 463 | + return { runId: latest, parsed: null }; | ||
| 464 | + } | ||
| 465 | +} | ||
| 466 | + | ||
| 320 | module.exports = { | 467 | module.exports = { |
| 321 | createProject, | 468 | createProject, |
| 322 | commitAfterAgent, | 469 | commitAfterAgent, |
| 323 | callAgent, | 470 | callAgent, |
| 324 | runDocsPhase, | 471 | runDocsPhase, |
| 472 | + runUnit, | ||
| 325 | getProjectState, | 473 | getProjectState, |
| 326 | }; | 474 | }; |
server/tests/devloop/loop-controller.test.js
| @@ -104,12 +104,14 @@ describe('loop-controller (P1)', () => { | @@ -104,12 +104,14 @@ describe('loop-controller (P1)', () => { | ||
| 104 | expect(missing).toEqual([]); | 104 | expect(missing).toEqual([]); |
| 105 | }); | 105 | }); |
| 106 | 106 | ||
| 107 | - it('units.json has 3 units with l2_rounds/l2_budget', () => { | 107 | + it('units.json has 3 units with project-level l2_rounds/l2_budget', () => { |
| 108 | const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | 108 | const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); |
| 109 | expect(u.units).toHaveLength(3); | 109 | expect(u.units).toHaveLength(3); |
| 110 | + expect(typeof u.l2_rounds).toBe('number'); | ||
| 111 | + expect(typeof u.l2_budget).toBe('number'); | ||
| 110 | for (const x of u.units) { | 112 | for (const x of u.units) { |
| 111 | - expect(typeof x.l2_rounds).toBe('number'); | ||
| 112 | - expect(typeof x.l2_budget).toBe('number'); | 113 | + expect(x.status).toBe('pending'); |
| 114 | + expect(typeof x.retry_budget).toBe('number'); | ||
| 113 | } | 115 | } |
| 114 | }); | 116 | }); |
| 115 | 117 |
server/tests/devloop/routes.test.js
| @@ -106,6 +106,46 @@ describe('routes/devloop', () => { | @@ -106,6 +106,46 @@ describe('routes/devloop', () => { | ||
| 106 | expect(s.units).toHaveLength(3); | 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); | ||
| 112 | + }); | ||
| 113 | + | ||
| 114 | + it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { | ||
| 115 | + const cid = 'c-route-p2-' + Math.random().toString(36).slice(2, 7); | ||
| 116 | + created.push(cid); | ||
| 117 | + // 准备项目 | ||
| 118 | + const r1 = await fetch(`${base}/api/devloop/projects`, { | ||
| 119 | + method: 'POST', | ||
| 120 | + headers: { 'Content-Type': 'application/json' }, | ||
| 121 | + body: JSON.stringify({ company_id: cid, requirement: '做一个 TODO API' }), | ||
| 122 | + }); | ||
| 123 | + const j1 = await r1.json(); | ||
| 124 | + const pid = j1.pid; | ||
| 125 | + // 跑 todo-be | ||
| 126 | + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' }); | ||
| 127 | + expect(r2.status).toBe(200); | ||
| 128 | + const j2 = await r2.json(); | ||
| 129 | + expect(j2.ok).toBe(true); | ||
| 130 | + expect(j2.unit_id).toBe('todo-be'); | ||
| 131 | + expect(j2.attempts).toBe(2); | ||
| 132 | + expect(j2.final_state).toBe('passed'); | ||
| 133 | + expect(j2.history).toHaveLength(4); | ||
| 134 | + }); | ||
| 135 | + | ||
| 136 | + it('POST /run?only on unknown unit returns 404', async () => { | ||
| 137 | + const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7); | ||
| 138 | + created.push(cid); | ||
| 139 | + const r1 = await fetch(`${base}/api/devloop/projects`, { | ||
| 140 | + method: 'POST', | ||
| 141 | + headers: { 'Content-Type': 'application/json' }, | ||
| 142 | + body: JSON.stringify({ company_id: cid, requirement: 'x' }), | ||
| 143 | + }); | ||
| 144 | + const pid = (await r1.json()).pid; | ||
| 145 | + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=no-such-unit`, { method: 'POST' }); | ||
| 146 | + expect(r2.status).toBe(404); | ||
| 147 | + }); | ||
| 148 | + | ||
| 109 | it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { | 149 | it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { |
| 110 | const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); | 150 | const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); |
| 111 | expect(r.status).toBe(404); | 151 | expect(r.status).toBe(404); |
server/tests/devloop/run-unit.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * loop-controller.runUnit (P2 L1 单 Unit Loop) 单测 | ||
| 3 | + * | ||
| 4 | + * 验: | ||
| 5 | + * - state 链路 pending → coding → testing → failed → coding → testing → passed | ||
| 6 | + * - coder.log 第二条决策含 last_failure (反思生效) | ||
| 7 | + * - 沙箱违例被工具层拒绝 | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | ||
| 11 | + | ||
| 12 | +const fs = require('fs'); | ||
| 13 | +const path = require('path'); | ||
| 14 | +const os = require('os'); | ||
| 15 | + | ||
| 16 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ru-')); | ||
| 17 | +process.env.HOME = TEST_HOME; | ||
| 18 | + | ||
| 19 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 20 | +const unitRegistry = require('../../services/devloop/unit-registry'); | ||
| 21 | +const lc = require('../../src/core/devloop/loop-controller'); | ||
| 22 | +const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol'); | ||
| 23 | +const { assertWritable } = require('../../src/tools/devloop/_sandbox'); | ||
| 24 | +const { ToolSandboxError } = require('../../src/tools/devloop/_errors'); | ||
| 25 | + | ||
| 26 | +describe('runUnit (P2 L1)', () => { | ||
| 27 | + let pid, cid; | ||
| 28 | + const created = []; | ||
| 29 | + | ||
| 30 | + beforeAll(async () => { | ||
| 31 | + pathResolver._resetForTesting(); | ||
| 32 | + ClaudeCliProtocol.resetMockState(); | ||
| 33 | + cid = 'c-rununit-' + Math.random().toString(36).slice(2, 7); | ||
| 34 | + created.push(cid); | ||
| 35 | + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p2 test' }); | ||
| 36 | + pid = proj.pid; | ||
| 37 | + await lc.runDocsPhase(pid, 'TODO list'); | ||
| 38 | + }); | ||
| 39 | + | ||
| 40 | + afterAll(() => { | ||
| 41 | + for (const c of created) { | ||
| 42 | + try { | ||
| 43 | + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); | ||
| 44 | + } catch { /* ignore */ } | ||
| 45 | + } | ||
| 46 | + }); | ||
| 47 | + | ||
| 48 | + it('todo-be: fail → retry → pass in 2 attempts', async () => { | ||
| 49 | + // 验证起始 status=pending | ||
| 50 | + expect(unitRegistry.get(pid, 'todo-be').status).toBe('pending'); | ||
| 51 | + | ||
| 52 | + // 监听 unit-registry 的 SSE 事件来捕获完整 FSM 链路 | ||
| 53 | + const fsmTrail = []; | ||
| 54 | + unitRegistry.attachSseBroker({ | ||
| 55 | + emit: (_pid, kind, ev) => { | ||
| 56 | + if (kind === 'unit.state' && ev.unit_id === 'todo-be') { | ||
| 57 | + fsmTrail.push(`${ev.from}→${ev.to}`); | ||
| 58 | + } | ||
| 59 | + }, | ||
| 60 | + }); | ||
| 61 | + | ||
| 62 | + const r = await lc.runUnit(pid, 'todo-be'); | ||
| 63 | + expect(r.ok).toBe(true); | ||
| 64 | + expect(r.attempts).toBe(2); | ||
| 65 | + expect(r.final_state).toBe('passed'); | ||
| 66 | + | ||
| 67 | + // FSM 链路: pending→coding (att 1) · coding→testing · testing→failed · failed→coding (att 2) · coding→testing · testing→passed | ||
| 68 | + expect(fsmTrail).toEqual([ | ||
| 69 | + 'pending→coding', | ||
| 70 | + 'coding→testing', | ||
| 71 | + 'testing→failed', | ||
| 72 | + 'failed→coding', | ||
| 73 | + 'coding→testing', | ||
| 74 | + 'testing→passed', | ||
| 75 | + ]); | ||
| 76 | + }); | ||
| 77 | + | ||
| 78 | + it('coder.log second decision references previous failure', () => { | ||
| 79 | + const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log'); | ||
| 80 | + const log = fs.readFileSync(logPath, 'utf8'); | ||
| 81 | + // 必须至少有两条 attempt | ||
| 82 | + expect(log).toMatch(/attempt: 1/); | ||
| 83 | + expect(log).toMatch(/attempt: 2/); | ||
| 84 | + // 第二条 entry 必须包含反思字段 | ||
| 85 | + const att2Section = log.split('attempt: 2')[1] || ''; | ||
| 86 | + expect(att2Section).toMatch(/last_failure/); | ||
| 87 | + expect(att2Section).toMatch(/last_cause/); | ||
| 88 | + expect(att2Section).toMatch(/this_fix/); | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + it('produced runs/{runId}/result.json for both attempts', () => { | ||
| 92 | + const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs'); | ||
| 93 | + const ids = fs.readdirSync(runsDir).sort(); | ||
| 94 | + expect(ids.length).toBeGreaterThanOrEqual(2); | ||
| 95 | + // 第一个 result.ok=false, 最后 result.ok=true | ||
| 96 | + const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8')); | ||
| 97 | + const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8')); | ||
| 98 | + expect(first.ok).toBe(false); | ||
| 99 | + expect(last.ok).toBe(true); | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + it('sandbox enforcement: writing outside unit dir is rejected by _sandbox.assertWritable', () => { | ||
| 103 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 104 | + const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' }; | ||
| 105 | + // 合法 | ||
| 106 | + expect(() => assertWritable( | ||
| 107 | + path.join(root, 'units/todo-be/code/server.js'), | ||
| 108 | + ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'], | ||
| 109 | + ctx, | ||
| 110 | + )).not.toThrow(); | ||
| 111 | + // 越权: 写别的 unit | ||
| 112 | + expect(() => assertWritable( | ||
| 113 | + path.join(root, 'units/todo-fe/code/foo.js'), | ||
| 114 | + ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'], | ||
| 115 | + ctx, | ||
| 116 | + )).toThrow(ToolSandboxError); | ||
| 117 | + // 越权: 写 prd | ||
| 118 | + expect(() => assertWritable( | ||
| 119 | + path.join(root, 'prd/anything.html'), | ||
| 120 | + ['units/{unitId}/code/**'], | ||
| 121 | + ctx, | ||
| 122 | + )).toThrow(ToolSandboxError); | ||
| 123 | + }); | ||
| 124 | +}); |