From 3c14a4dd35822b5dc56cb7b0f3ca37bf41dc31b8 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 13 May 2026 17:38:50 +0800 Subject: [PATCH] feat(devloop): P2 L1 single-unit loop — Coder + Tester with retry-with-reflection --- fixtures/devloop-mock/devloop-architect.json | 14 +++++++++++++- fixtures/devloop-mock/devloop-coder.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ fixtures/devloop-mock/devloop-tester.json | 45 +++++++++++++++++++++++++++++++++++++++++++++ packages/acp-core/protocol/ClaudeCliProtocol.js | 41 ++++++++++++++++++++++++++++++++--------- scripts/phase1-verify.js | 8 +++++--- scripts/phase2-verify.js | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/routes/devloop.js | 32 ++++++++++++++++++++++++++++++++ server/src/core/devloop/loop-controller.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- server/tests/devloop/loop-controller.test.js | 8 +++++--- server/tests/devloop/routes.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ server/tests/devloop/run-unit.test.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 686 insertions(+), 17 deletions(-) create mode 100644 fixtures/devloop-mock/devloop-coder.json create mode 100644 fixtures/devloop-mock/devloop-tester.json create mode 100644 scripts/phase2-verify.js create mode 100644 server/tests/devloop/run-unit.test.js 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 snapshot

Snapshot

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); + }); +}); -- libgit2 0.21.4