diff --git a/fixtures/devloop-mock/devloop-architect.json b/fixtures/devloop-mock/devloop-architect.json
index 245b002..c11379f 100644
--- a/fixtures/devloop-mock/devloop-architect.json
+++ b/fixtures/devloop-mock/devloop-architect.json
@@ -46,7 +46,7 @@
},
{
"path": "units.json",
- "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"
+ "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"
},
{
"path": "integration/spec.json",
@@ -57,6 +57,18 @@
"content": "
SDD rev 1 snapshotSnapshot
Archive of SDD rev 1, see sdd/ for live version.
"
},
{
+ "path": "units/todo-be/spec.json",
+ "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"
+ },
+ {
+ "path": "units/todo-db/spec.json",
+ "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"
+ },
+ {
+ "path": "units/todo-fe/spec.json",
+ "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"
+ },
+ {
"path": "agent-state/architect.json",
"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\"}"
},
diff --git a/fixtures/devloop-mock/devloop-coder.json b/fixtures/devloop-mock/devloop-coder.json
new file mode 100644
index 0000000..95bb803
--- /dev/null
+++ b/fixtures/devloop-mock/devloop-coder.json
@@ -0,0 +1,55 @@
+{
+ "_doc": "Mock fixture for Coder Agent (todo-be unit). sequence[0]=implement (first try, intentionally buggy in retry demo), sequence[1]=retry (reflective fix).",
+ "sequence": [
+ {
+ "_variant": "implement",
+ "chunks": [
+ "## step 0 · 读上下文\n",
+ "已读 spec.json acceptance_criteria · 3 条\n",
+ "\n## step 1-5 · write code/ + contract.json\n",
+ "POST /todos · GET /todos\n",
+ "\n## step 6 · append coder.log\n",
+ "implement 首次实现完成\n"
+ ],
+ "result": "mock-ok devloop-coder[implement] todo-be: code/ + contract.json + coder.log",
+ "files": [
+ {
+ "path": "units/todo-be/code/server.js",
+ "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"
+ },
+ {
+ "path": "units/todo-be/contract.json",
+ "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"
+ },
+ {
+ "path": "units/todo-be/coder.log",
+ "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"
+ }
+ ]
+ },
+ {
+ "_variant": "retry",
+ "chunks": [
+ "## step 0 · 读 failures + hint + 自己 coder.log\n",
+ "上次失败:POST /todos with empty body 返 201 而期望 400\n",
+ "\n## step 1 · 反思\n",
+ "上次错: 没校验 req.body.text\n",
+ "这次改: 在 handler 入口 if (!req.body?.text) return res.status(400)\n",
+ "因为: 400 是参数错语义,201 表示创建成功\n",
+ "\n## step 2-5 · 精准 patch + append coder.log\n",
+ "改了一行,重写 coder.log retry 模板\n"
+ ],
+ "result": "mock-ok devloop-coder[retry] todo-be: added body validation; 400 branch covered",
+ "files": [
+ {
+ "path": "units/todo-be/code/server.js",
+ "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"
+ },
+ {
+ "path": "units/todo-be/coder.log",
+ "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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock/devloop-tester.json b/fixtures/devloop-mock/devloop-tester.json
new file mode 100644
index 0000000..7865497
--- /dev/null
+++ b/fixtures/devloop-mock/devloop-tester.json
@@ -0,0 +1,45 @@
+{
+ "_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).",
+ "sequence": [
+ {
+ "_variant": "fail",
+ "chunks": [
+ "## step 0 · 读 spec.json + contract.json + code/server.js\n",
+ "\n## step 1-2 · 写 tests/todo-be.spec.js,覆盖 3 acceptance + 2 status code\n",
+ "\n## step 3 · 跑 vitest\n",
+ "1 of 3 failed\n",
+ "\n## step 4 · parse_vitest + hint\n",
+ "失败: POST /todos with empty body 返 201, 期望 400\n",
+ "\n## step 5 · bug_open\n"
+ ],
+ "result": "mock-ok devloop-tester[fail] todo-be: 2/3 pass · failure has hint",
+ "files": [
+ {
+ "path": "units/todo-be/tests/todo-be.spec.js",
+ "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"
+ },
+ {
+ "path": "units/todo-be/runs/run-mock-attempt-1/result.json",
+ "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"
+ }
+ ]
+ },
+ {
+ "_variant": "pass",
+ "chunks": [
+ "## step 0 · 读 spec.json + contract.json + code/server.js (已被 Coder 改动)\n",
+ "\n## step 1 · 跑现有 tests (复用上一轮 spec)\n",
+ "\n## step 2 · parse_vitest\n",
+ "3 of 3 passed\n",
+ "\n## step 3 · bug_transition fixed\n"
+ ],
+ "result": "mock-ok devloop-tester[pass] todo-be: 3/3 pass · L1 green",
+ "files": [
+ {
+ "path": "units/todo-be/runs/run-mock-attempt-2/result.json",
+ "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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js
index f366f95..3d85913 100644
--- a/packages/acp-core/protocol/ClaudeCliProtocol.js
+++ b/packages/acp-core/protocol/ClaudeCliProtocol.js
@@ -524,38 +524,51 @@ class ClaudeCliProtocol extends EventEmitter {
/**
* Mock 模式 sendPrompt 实现:
* 1. 找 fixture: {mockFixtureDir}/{agentName}.json (若无,用 default.json,再无 → 走 echo fallback)
- * 2. fixture 形如:{ chunks: ["...text...", ...], result: "summary text" }
- * 3. 同步 emit session_update (agent_message_chunk) + acp_event session/result
+ * 2. fixture 两种形态:
+ * (a) 单次: { chunks, result, files }
+ * (b) 多次: { sequence: [ {chunks,result,files}, ... ] }
+ * 每次调用消耗一个 variant;耗尽后 stick on 最后一个。
+ * 3. 同步 emit session_update (agent_message_chunk) + mock/files + session/result
* 4. resolve
*
- * 不发起任何子进程,不依赖外部网络;用于 phase0 测试。
+ * 不发起任何子进程,不依赖外部网络;用于 phase0/P1/P2 测试。
*/
async _sendMockPrompt(text) {
const agentName = this.agentInfo?.name || this.options.agentName || 'default';
const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`);
const defaultPath = path.join(this._mockFixtureDir, 'default.json');
- let fixture;
+ let raw;
if (fs.existsSync(fixturePath)) {
try {
- fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+ raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
} catch (e) {
console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`);
}
}
- if (!fixture && fs.existsSync(defaultPath)) {
+ if (!raw && fs.existsSync(defaultPath)) {
try {
- fixture = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
+ raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
} catch { /* ignore */ }
}
- if (!fixture) {
+ if (!raw) {
// echo fallback: 把 prompt 头 80 字符回显
- fixture = {
+ raw = {
chunks: [`[mock] received prompt for ${agentName}: `, text.slice(0, 80)],
result: `mock-ok (no fixture for ${agentName})`,
};
}
+ // 选 variant:有 sequence → 按调用计数取;否则就是 raw 本身
+ let fixture = raw;
+ if (Array.isArray(raw.sequence) && raw.sequence.length > 0) {
+ const key = `${this._mockFixtureDir}:${agentName}`;
+ const idx = ClaudeCliProtocol._mockCallCount.get(key) || 0;
+ const pick = Math.min(idx, raw.sequence.length - 1);
+ fixture = raw.sequence[pick] || raw.sequence[0];
+ ClaudeCliProtocol._mockCallCount.set(key, idx + 1);
+ }
+
this.sessionId = `claude-cli-mock-${Date.now()}`;
// 发 chunks(同步触发 session_update,模拟 stream_event delta)
@@ -605,4 +618,14 @@ class ClaudeCliProtocol extends EventEmitter {
}
}
+// Mock 模式下的"调用次数"全局计数 (跨实例共享,因为同个 fixture 会被多个
+// protocol 实例消费,例如 LoopController 每次 callAgent 都 new 一个 protocol)。
+// key = `${mockFixtureDir}:${agentName}`,value = 已用过的 variant 数量。
+ClaudeCliProtocol._mockCallCount = new Map();
+
+// 测试 / runUnit 跑前可调用清零,确保 sequence 重新从 0 开始
+ClaudeCliProtocol.resetMockState = function () {
+ ClaudeCliProtocol._mockCallCount.clear();
+};
+
module.exports = { ClaudeCliProtocol };
diff --git a/scripts/phase1-verify.js b/scripts/phase1-verify.js
index 4284a5d..c05f5c9 100644
--- a/scripts/phase1-verify.js
+++ b/scripts/phase1-verify.js
@@ -122,14 +122,16 @@ async function main() {
return { detail: `${required.length}/${required.length} files present` };
});
- await step('units.json has 3 units with l2_rounds/l2_budget', () => {
+ await step('units.json has 3 units + project-level l2_rounds/l2_budget', () => {
const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
if (!Array.isArray(u.units) || u.units.length !== 3) {
throw new Error('want 3 units, got: ' + (u.units?.length || 0));
}
+ if (typeof u.l2_rounds !== 'number') throw new Error('l2_rounds missing');
+ if (typeof u.l2_budget !== 'number') throw new Error('l2_budget missing');
for (const x of u.units) {
- if (typeof x.l2_rounds !== 'number') throw new Error(`unit ${x.id} missing l2_rounds`);
- if (typeof x.l2_budget !== 'number') throw new Error(`unit ${x.id} missing l2_budget`);
+ if (x.status !== 'pending') throw new Error(`unit ${x.id} status=${x.status}, want pending`);
+ if (typeof x.retry_budget !== 'number') throw new Error(`unit ${x.id} missing retry_budget`);
}
return { detail: '3 units · l2 fields present · DAG: ' + u.units.map(x => x.id).join(' → ') };
});
diff --git a/scripts/phase2-verify.js b/scripts/phase2-verify.js
new file mode 100644
index 0000000..ee9c3de
--- /dev/null
+++ b/scripts/phase2-verify.js
@@ -0,0 +1,186 @@
+#!/usr/bin/env node
+/**
+ * scripts/phase2-verify.js
+ *
+ * P2 L1 单 Unit 验收 (mock 模式):
+ * 1. 启动 devloop router
+ * 2. POST /api/devloop/projects 创建 todo-demo
+ * 3. POST /api/devloop/projects/:pid/run?only=todo-be 跑 L1 loop
+ * 4. 校验 state 链路: pending → coding → testing → failed → coding → testing → passed
+ * 5. 校验 coder.log 第二条决策含 last_failure (反思生效)
+ * 6. 校验 runs/ 下至少 2 个 result.json,首失败末通过
+ * 7. 校验沙箱越权被 _sandbox.assertWritable 拒绝
+ *
+ * 通过 → exit 0;失败 → exit 1
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+
+const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
+require('module').globalPaths.push(serverNodeModules);
+const express = (() => {
+ try { return require('express'); }
+ catch {
+ return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
+ }
+})();
+
+const pathResolver = require('../server/services/devloop/path-resolver');
+const unitRegistry = require('../server/services/devloop/unit-registry');
+const devloopRouter = require('../server/routes/devloop');
+const { assertWritable } = require('../server/src/tools/devloop/_sandbox');
+const { ToolSandboxError } = require('../server/src/tools/devloop/_errors');
+
+const results = [];
+function step(name, fn) {
+ return (async () => {
+ try {
+ const r = await fn();
+ results.push({ name, ok: true, detail: r?.detail || '' });
+ return r;
+ } catch (e) {
+ results.push({ name, ok: false, reason: e.message });
+ throw e;
+ }
+ })();
+}
+
+async function startApp() {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/devloop', devloopRouter);
+ return new Promise((resolve) => {
+ const srv = app.listen(0, '127.0.0.1', () => {
+ resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
+ });
+ });
+}
+
+async function main() {
+ const { srv, base } = await startApp();
+ const cid = 'c-phase2-' + Date.now().toString(36);
+ let pid;
+ const fsmTrail = [];
+
+ try {
+ // 准备:跑 PM + Architect 把 units.json 写好
+ const r0 = await step('POST /projects (PM + Architect)', async () => {
+ const resp = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ company_id: cid,
+ name: 'todo-demo',
+ requirement: '做一个 TODO 列表 API + Vue 前端',
+ }),
+ });
+ if (resp.status !== 200) throw new Error('status ' + resp.status);
+ const j = await resp.json();
+ pid = j.pid;
+ return { detail: `pid=${pid}` };
+ });
+
+ // 监听 unit-registry SSE 收 FSM 转换
+ unitRegistry.attachSseBroker({
+ emit: (_pid, kind, ev) => {
+ if (kind === 'unit.state' && ev.unit_id === 'todo-be') {
+ fsmTrail.push(`${ev.from}→${ev.to}`);
+ }
+ },
+ });
+
+ await step('POST /run?only=todo-be (L1 loop)', async () => {
+ const resp = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' });
+ if (resp.status !== 200) throw new Error('status ' + resp.status);
+ const j = await resp.json();
+ if (!j.ok) throw new Error('runUnit ok=false');
+ if (j.attempts !== 2) throw new Error('want 2 attempts, got ' + j.attempts);
+ if (j.final_state !== 'passed') throw new Error('final_state=' + j.final_state);
+ return { detail: `attempts=2 · final=passed · history=${j.history.length} steps` };
+ });
+
+ await step('FSM trail: pending → coding → testing → failed → coding → testing → passed', () => {
+ const expected = [
+ 'pending→coding',
+ 'coding→testing',
+ 'testing→failed',
+ 'failed→coding',
+ 'coding→testing',
+ 'testing→passed',
+ ];
+ if (JSON.stringify(fsmTrail) !== JSON.stringify(expected)) {
+ throw new Error('got: ' + fsmTrail.join(', '));
+ }
+ return { detail: fsmTrail.join(' · ') };
+ });
+
+ await step('coder.log second entry references previous failure', () => {
+ const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log');
+ const log = fs.readFileSync(logPath, 'utf8');
+ if (!/attempt: 1/.test(log)) throw new Error('missing attempt: 1');
+ if (!/attempt: 2/.test(log)) throw new Error('missing attempt: 2');
+ const att2 = log.split('attempt: 2')[1] || '';
+ for (const field of ['last_failure', 'last_cause', 'this_fix', 'rationale']) {
+ if (!att2.includes(field)) throw new Error('attempt:2 missing ' + field);
+ }
+ return { detail: 'reflection template (last_failure/last_cause/this_fix/rationale) present' };
+ });
+
+ await step('runs/ has ≥2 result.json with first.ok=false, last.ok=true', () => {
+ const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs');
+ const ids = fs.readdirSync(runsDir).sort();
+ if (ids.length < 2) throw new Error('want ≥2 runs, got ' + ids.length);
+ const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8'));
+ const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8'));
+ if (first.ok !== false) throw new Error('first run.ok should be false');
+ if (last.ok !== true) throw new Error('last run.ok should be true');
+ return { detail: `${ids.length} runs · first.ok=false · last.ok=true` };
+ });
+
+ await step('sandbox: writing other unit / prd is rejected', () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const allowList = ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'];
+ const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' };
+ // allowed
+ assertWritable(path.join(root, 'units/todo-be/code/server.js'), allowList, ctx);
+ // denied (cross-unit)
+ let blockedCross = false;
+ try { assertWritable(path.join(root, 'units/todo-fe/code/foo.js'), allowList, ctx); }
+ catch (e) { blockedCross = e instanceof ToolSandboxError; }
+ if (!blockedCross) throw new Error('cross-unit write was NOT blocked');
+ // denied (prd)
+ let blockedPrd = false;
+ try { assertWritable(path.join(root, 'prd/index.html'), allowList, ctx); }
+ catch (e) { blockedPrd = e instanceof ToolSandboxError; }
+ if (!blockedPrd) throw new Error('prd write was NOT blocked');
+ return { detail: 'cross-unit + prd writes blocked by ToolSandboxError' };
+ });
+
+ } finally {
+ srv.close();
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
+ } catch { /* ignore */ }
+ }
+
+ let pass = 0, fail = 0;
+ console.log('\n┌─ DevLoop Phase 2 — L1 Single Unit Acceptance ────');
+ for (const r of results) {
+ const tag = r.ok ? '✓' : '✗';
+ console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`);
+ r.ok ? pass++ : fail++;
+ }
+ console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
+ process.exit(fail ? 1 : 0);
+}
+
+main().catch(e => {
+ console.error('\n[phase2-verify] fatal:', e);
+ for (const r of results) {
+ console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
+ }
+ process.exit(1);
+});
diff --git a/server/routes/devloop.js b/server/routes/devloop.js
index 1dae9f3..6da93a5 100644
--- a/server/routes/devloop.js
+++ b/server/routes/devloop.js
@@ -76,6 +76,38 @@ router.get('/projects', h(async (_req, res) => {
res.json({ projects });
}));
+// POST /api/devloop/projects/:pid/run?only=
+// P2: only= 跑单 unit;不带 only → 501 (P3 才补全 runProject)
+router.post('/projects/:pid/run', h(async (req, res) => {
+ const { pid } = req.params;
+ const { only } = req.query;
+ if (!only) {
+ return res.status(501).json({ error: 'P2 only supports ?only=; full project run lands in P3' });
+ }
+ try {
+ const result = await loopController.runUnit(pid, only, { mock: true });
+ res.json({
+ ok: result.ok,
+ pid, unit_id: only,
+ attempts: result.attempts,
+ final_state: result.final_state,
+ history: result.history.map(h => ({
+ agent: h.agent, attempt: h.attempt,
+ ok: h.ok, files: h.files?.length || 0,
+ commit: h.commit || null,
+ })),
+ });
+ } catch (e) {
+ if (/Unknown project/.test(e.message)) {
+ return res.status(404).json({ error: 'unknown project: ' + pid });
+ }
+ if (/not found in project/.test(e.message)) {
+ return res.status(404).json({ error: 'unknown unit: ' + only });
+ }
+ throw e;
+ }
+}));
+
// GET /api/devloop/projects/:pid/state
router.get('/projects/:pid/state', h(async (req, res) => {
try {
diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js
index bc2ca51..c4092d9 100644
--- a/server/src/core/devloop/loop-controller.js
+++ b/server/src/core/devloop/loop-controller.js
@@ -23,6 +23,7 @@ const path = require('path');
const pathResolver = require('../../../services/devloop/path-resolver');
const gitVault = require('../../../services/devloop/git-vault');
const timelineLogger = require('../../../services/devloop/timeline-logger');
+const unitRegistry = require('../../../services/devloop/unit-registry');
const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');
const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');
@@ -191,7 +192,16 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) {
}
const abs = path.join(projectRoot, f.path);
fs.mkdirSync(path.dirname(abs), { recursive: true });
- fs.writeFileSync(abs, f.content);
+ let content = f.content;
+ // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_")
+ if (f.path === 'units.json' || f.path.endsWith('/units.json')) {
+ try {
+ const u = JSON.parse(content);
+ u.project_id = pid;
+ content = JSON.stringify(u, null, 2);
+ } catch { /* leave as-is, downstream will fail validation */ }
+ }
+ fs.writeFileSync(abs, content);
filesWritten.push(f.path);
}
@@ -317,10 +327,148 @@ function getProjectState(pid) {
return out;
}
+/**
+ * runUnit — L1 单 Unit 循环 (P2)。
+ *
+ * 串行 Coder(implement) → Tester(test)。失败时 retryWithReflection 把
+ * 上一次 failure + coder.log 摘要打回去重跑 Coder + Tester,直到通过或耗
+ * 尽 retry_budget。每个状态转换走 unit-registry FSM。
+ *
+ * @param {string} pid
+ * @param {string} unitId
+ * @param {object} opts
+ * - mock?: boolean (默认 true,P2 阶段)
+ * - onChunk?: (agent, text) => void
+ * - resetMockSequence?: boolean (默认 true,跑前清零 fixture sequence 计数)
+ * @returns { ok, unit_id, attempts, final_state, history: [{ agent, attempt, ... }] }
+ */
+async function runUnit(pid, unitId, opts = {}) {
+ if (opts.resetMockSequence !== false) {
+ ClaudeCliProtocol.resetMockState();
+ }
+
+ const unit = unitRegistry.get(pid, unitId);
+ const budget = unit.retry_budget || 5;
+ const history = [];
+ let attempt = 0;
+ let finalOk = false;
+
+ while (attempt < budget) {
+ attempt++;
+
+ // ── coding ───────────────────────────────────────────────
+ unitRegistry.markCoding(pid, unitId);
+ const coderHistorySummary = _buildRetryContext(history);
+ const coderPrompt = attempt === 1
+ ? `Mode: implement\nUnit: ${unitId}\n\nRead units/${unitId}/spec.json and produce code/ + contract.json + coder.log.`
+ : `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.`;
+ const coderResult = await callAgent(pid, 'devloop-coder', coderPrompt, {
+ mock: opts.mock !== false,
+ onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-coder', t) : undefined,
+ });
+ history.push({
+ agent: 'devloop-coder', attempt,
+ files: coderResult.files_written,
+ summary: coderResult.result_text,
+ commit: coderResult.commit?.sha,
+ });
+
+ // ── testing ──────────────────────────────────────────────
+ unitRegistry.markTesting(pid, unitId);
+ 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.`;
+ const testerResult = await callAgent(pid, 'devloop-tester', testerPrompt, {
+ mock: opts.mock !== false,
+ onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-tester', t) : undefined,
+ });
+
+ const resultRef = _findLatestTesterResult(pid, unitId);
+ const testerOk = resultRef ? !!resultRef.parsed?.ok : false;
+ history.push({
+ agent: 'devloop-tester', attempt,
+ files: testerResult.files_written,
+ summary: testerResult.result_text,
+ commit: testerResult.commit?.sha,
+ ok: testerOk,
+ failures: resultRef?.parsed?.failures || [],
+ });
+
+ // ── transition ──────────────────────────────────────────
+ if (testerOk) {
+ unitRegistry.pass(pid, unitId, unit.contract_hash);
+ finalOk = true;
+ break;
+ } else {
+ const reason = resultRef?.parsed?.failures?.[0]?.test
+ || resultRef?.parsed?.failures?.[0]?.hint
+ || `tester reported failure (attempt ${attempt})`;
+ unitRegistry.fail(pid, unitId, reason);
+ // 如果还有预算 → 再循环;否则 markBlocked
+ if (attempt >= budget) {
+ unitRegistry.block(pid, unitId, `retry_budget_exceeded after ${attempt} attempts`);
+ break;
+ }
+ // 把 unit 从 failed → coding 由下一轮 markCoding 完成 (FSM 允许)
+ }
+ }
+
+ try {
+ timelineLogger.append(pid, {
+ source: 'system',
+ kind: 'unit.run_complete',
+ ref: unitId,
+ summary: `Unit ${unitId} ${finalOk ? 'passed' : 'blocked'} after ${attempt} attempt(s)`,
+ payload: { ok: finalOk, attempts: attempt, budget },
+ });
+ } catch { /* swallow */ }
+
+ return {
+ ok: finalOk,
+ unit_id: unitId,
+ attempts: attempt,
+ final_state: unitRegistry.get(pid, unitId).status,
+ history,
+ };
+}
+
+/** 把 history 数组压成给 Coder 反思用的 markdown 摘要 */
+function _buildRetryContext(history) {
+ if (!history.length) return '';
+ const lines = ['## Retry History'];
+ for (const h of history) {
+ lines.push(`- attempt ${h.attempt} · ${h.agent}: ${(h.summary || '').slice(0, 120)}`);
+ if (h.agent === 'devloop-tester' && Array.isArray(h.failures) && h.failures.length) {
+ for (const f of h.failures.slice(0, 3)) {
+ lines.push(` - failure: ${f.test || f.name || '(unnamed)'}: ${f.hint || f.stack?.slice(0, 80) || ''}`);
+ }
+ }
+ }
+ return lines.join('\n');
+}
+
+/**
+ * 找 units/{unitId}/runs/ 下最新一次 result.json (按字典序 — runId 含日期前缀)。
+ */
+function _findLatestTesterResult(pid, unitId) {
+ const runsDir = path.join(pathResolver.resolveUnitDir(pid, unitId), 'runs');
+ if (!fs.existsSync(runsDir)) return null;
+ const ids = fs.readdirSync(runsDir).filter(n => fs.statSync(path.join(runsDir, n)).isDirectory());
+ if (!ids.length) return null;
+ ids.sort();
+ const latest = ids[ids.length - 1];
+ const resultPath = path.join(runsDir, latest, 'result.json');
+ if (!fs.existsSync(resultPath)) return { runId: latest, parsed: null };
+ try {
+ return { runId: latest, parsed: JSON.parse(fs.readFileSync(resultPath, 'utf8')) };
+ } catch {
+ return { runId: latest, parsed: null };
+ }
+}
+
module.exports = {
createProject,
commitAfterAgent,
callAgent,
runDocsPhase,
+ runUnit,
getProjectState,
};
diff --git a/server/tests/devloop/loop-controller.test.js b/server/tests/devloop/loop-controller.test.js
index def2bbc..933ec0b 100644
--- a/server/tests/devloop/loop-controller.test.js
+++ b/server/tests/devloop/loop-controller.test.js
@@ -104,12 +104,14 @@ describe('loop-controller (P1)', () => {
expect(missing).toEqual([]);
});
- it('units.json has 3 units with l2_rounds/l2_budget', () => {
+ it('units.json has 3 units with project-level l2_rounds/l2_budget', () => {
const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
expect(u.units).toHaveLength(3);
+ expect(typeof u.l2_rounds).toBe('number');
+ expect(typeof u.l2_budget).toBe('number');
for (const x of u.units) {
- expect(typeof x.l2_rounds).toBe('number');
- expect(typeof x.l2_budget).toBe('number');
+ expect(x.status).toBe('pending');
+ expect(typeof x.retry_budget).toBe('number');
}
});
diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js
index 41f797c..25cc2fc 100644
--- a/server/tests/devloop/routes.test.js
+++ b/server/tests/devloop/routes.test.js
@@ -106,6 +106,46 @@ describe('routes/devloop', () => {
expect(s.units).toHaveLength(3);
});
+ it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => {
+ const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' });
+ expect(r.status).toBe(501);
+ });
+
+ it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
+ const cid = 'c-route-p2-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ // 准备项目
+ const r1 = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: '做一个 TODO API' }),
+ });
+ const j1 = await r1.json();
+ const pid = j1.pid;
+ // 跑 todo-be
+ const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' });
+ expect(r2.status).toBe(200);
+ const j2 = await r2.json();
+ expect(j2.ok).toBe(true);
+ expect(j2.unit_id).toBe('todo-be');
+ expect(j2.attempts).toBe(2);
+ expect(j2.final_state).toBe('passed');
+ expect(j2.history).toHaveLength(4);
+ });
+
+ it('POST /run?only on unknown unit returns 404', async () => {
+ const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ const r1 = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'x' }),
+ });
+ const pid = (await r1.json()).pid;
+ const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=no-such-unit`, { method: 'POST' });
+ expect(r2.status).toBe(404);
+ });
+
it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => {
const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`);
expect(r.status).toBe(404);
diff --git a/server/tests/devloop/run-unit.test.js b/server/tests/devloop/run-unit.test.js
new file mode 100644
index 0000000..4d97470
--- /dev/null
+++ b/server/tests/devloop/run-unit.test.js
@@ -0,0 +1,124 @@
+/**
+ * loop-controller.runUnit (P2 L1 单 Unit Loop) 单测
+ *
+ * 验:
+ * - state 链路 pending → coding → testing → failed → coding → testing → passed
+ * - coder.log 第二条决策含 last_failure (反思生效)
+ * - 沙箱违例被工具层拒绝
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ru-'));
+process.env.HOME = TEST_HOME;
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const unitRegistry = require('../../services/devloop/unit-registry');
+const lc = require('../../src/core/devloop/loop-controller');
+const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol');
+const { assertWritable } = require('../../src/tools/devloop/_sandbox');
+const { ToolSandboxError } = require('../../src/tools/devloop/_errors');
+
+describe('runUnit (P2 L1)', () => {
+ let pid, cid;
+ const created = [];
+
+ beforeAll(async () => {
+ pathResolver._resetForTesting();
+ ClaudeCliProtocol.resetMockState();
+ cid = 'c-rununit-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p2 test' });
+ pid = proj.pid;
+ await lc.runDocsPhase(pid, 'TODO list');
+ });
+
+ afterAll(() => {
+ for (const c of created) {
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
+ } catch { /* ignore */ }
+ }
+ });
+
+ it('todo-be: fail → retry → pass in 2 attempts', async () => {
+ // 验证起始 status=pending
+ expect(unitRegistry.get(pid, 'todo-be').status).toBe('pending');
+
+ // 监听 unit-registry 的 SSE 事件来捕获完整 FSM 链路
+ const fsmTrail = [];
+ unitRegistry.attachSseBroker({
+ emit: (_pid, kind, ev) => {
+ if (kind === 'unit.state' && ev.unit_id === 'todo-be') {
+ fsmTrail.push(`${ev.from}→${ev.to}`);
+ }
+ },
+ });
+
+ const r = await lc.runUnit(pid, 'todo-be');
+ expect(r.ok).toBe(true);
+ expect(r.attempts).toBe(2);
+ expect(r.final_state).toBe('passed');
+
+ // FSM 链路: pending→coding (att 1) · coding→testing · testing→failed · failed→coding (att 2) · coding→testing · testing→passed
+ expect(fsmTrail).toEqual([
+ 'pending→coding',
+ 'coding→testing',
+ 'testing→failed',
+ 'failed→coding',
+ 'coding→testing',
+ 'testing→passed',
+ ]);
+ });
+
+ it('coder.log second decision references previous failure', () => {
+ const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log');
+ const log = fs.readFileSync(logPath, 'utf8');
+ // 必须至少有两条 attempt
+ expect(log).toMatch(/attempt: 1/);
+ expect(log).toMatch(/attempt: 2/);
+ // 第二条 entry 必须包含反思字段
+ const att2Section = log.split('attempt: 2')[1] || '';
+ expect(att2Section).toMatch(/last_failure/);
+ expect(att2Section).toMatch(/last_cause/);
+ expect(att2Section).toMatch(/this_fix/);
+ });
+
+ it('produced runs/{runId}/result.json for both attempts', () => {
+ const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs');
+ const ids = fs.readdirSync(runsDir).sort();
+ expect(ids.length).toBeGreaterThanOrEqual(2);
+ // 第一个 result.ok=false, 最后 result.ok=true
+ const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8'));
+ const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8'));
+ expect(first.ok).toBe(false);
+ expect(last.ok).toBe(true);
+ });
+
+ it('sandbox enforcement: writing outside unit dir is rejected by _sandbox.assertWritable', () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' };
+ // 合法
+ expect(() => assertWritable(
+ path.join(root, 'units/todo-be/code/server.js'),
+ ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'],
+ ctx,
+ )).not.toThrow();
+ // 越权: 写别的 unit
+ expect(() => assertWritable(
+ path.join(root, 'units/todo-fe/code/foo.js'),
+ ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'],
+ ctx,
+ )).toThrow(ToolSandboxError);
+ // 越权: 写 prd
+ expect(() => assertWritable(
+ path.join(root, 'prd/anything.html'),
+ ['units/{unitId}/code/**'],
+ ctx,
+ )).toThrow(ToolSandboxError);
+ });
+});