Commit 3c14a4dd35822b5dc56cb7b0f3ca37bf41dc31b8

Authored by yibo
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>
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(&#39;/projects&#39;, h(async (_req, res) =&gt; { @@ -76,6 +76,38 @@ router.get(&#39;/projects&#39;, h(async (_req, res) =&gt; {
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(&#39;path&#39;); @@ -23,6 +23,7 @@ const path = require(&#39;path&#39;);
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(&#39;loop-controller (P1)&#39;, () =&gt; { @@ -104,12 +104,14 @@ describe(&#39;loop-controller (P1)&#39;, () =&gt; {
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(&#39;routes/devloop&#39;, () =&gt; { @@ -106,6 +106,46 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
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 +});