diff --git a/package-lock.json b/package-lock.json index 3ccbc52..1aeb33f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "maestro", "version": "0.1.0", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.175", "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^11.3.0", "fastify": "^4.28.1", @@ -15,7 +16,8 @@ "ws": "^8.18.0" }, "bin": { - "maestro": "dist/cli/index.js" + "maestro": "dist/cli/index.js", + "maestro-mcp": "dist/mcp/index.js" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", @@ -28,6 +30,166 @@ "node": ">=20" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.175.tgz", + "integrity": "sha512-RAuqHadT+JJqkUC0DsOHIivxTbe1+5Zu02SfIeJoxF1fNS/wazDCVGCmIMPQIRZ+d6HedV047tH7oYhRc2D1bQ==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.175", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.175", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.175", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.175", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.175", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.175", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.175", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.175" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.175.tgz", + "integrity": "sha512-ud/25HB7esWldzXwGaa+gK8/+A1dZf6yJ5HCKCJN7BMFFJdbCe28pwCwoh9zE+5imNSuXtlqSRDMuxa2fPsYGw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.175.tgz", + "integrity": "sha512-QLd1FCTtLb0peWqIIf/FTNQI/pSn/kFdy+SuxFbodPaHB0gehDhoFZ6ADm2HLS83tWxqGQAa0G5cHstkiuDzNQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.175.tgz", + "integrity": "sha512-mvCJ37aecg2dfzS8XZbwOfcmA45RFXUZwN84nXiKMnZFazZ6hn7daMmHlCXSp9zV0NpxbizLIb6SmmHgjefHzg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.175.tgz", + "integrity": "sha512-2FKNFy6JxIgYXitZ7ARO5wxQWHdDhmw3O+RkuohshPQ+10n5Zf0CpX7Lx2Vq2vz0DFRCiY9cYiPHf8hAI7ZWWg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.175.tgz", + "integrity": "sha512-vymQcmn39+BQ8JYwUrafPqgxbpMFBGLfV7PPIxQSsi3z4iBwciW4csb7KwpRaehODa3sD69HruAFpOUkJfMkzA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.175.tgz", + "integrity": "sha512-4YUpjcLbDqTYIuvb7gLWVRM3J9CiTisuiEMnckv8lrBWhj5AN0ULLG5pWTOxw4Pzsbja5pAdf9UMqzsKFiukUw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.175.tgz", + "integrity": "sha512-PwiduMKtisfEQRH8KP6bQ7T+XTC1yNFutrN3v1wQS7BuNTm2bPdXFkW97++OcjW5H4RgdVibIzQZ1V12Hcy4EA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.175", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.175.tgz", + "integrity": "sha512-II4yfIrKCrscig918R6hEOvqEejX56nH8+NI9KvGY47g0rZnPgGgXZCQrRC5ZgRihqNiwF1i6faJxvmmOEx5Rw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.104.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz", + "integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -580,6 +742,13 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1245,6 +1414,13 @@ "fast-decode-uri-component": "^1.0.1" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, "node_modules/fast-uri": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", @@ -1587,6 +1763,20 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2348,6 +2538,17 @@ "node": ">= 10.x" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2430,6 +2631,13 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", diff --git a/package.json b/package.json index 63e618c..e4414c5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test": "tsx --test test/*.test.ts" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.175", "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^11.3.0", "fastify": "^4.28.1", diff --git a/scripts/poc-exec.mjs b/scripts/poc-exec.mjs new file mode 100644 index 0000000..5101b42 --- /dev/null +++ b/scripts/poc-exec.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * PoC:最小执行闭环(DESIGN.md §14.1) + * + * 临时 git repo + 临时 daemon(:4528, 独立 MAESTRO_DATA_DIR) + autonomy=auto-easy + * + 一个 Easy 任务(operations=「在 README.md 追加一行 hello maestro」) + * → 等编排器在 worktree 真起 headless Claude Code 跑完 + * → 断言:任务到 exec_review、maestro/ 分支上有 commit、README 真的被改了。 + * + * 前提:本机有 claude 凭证(claude CLI 已登录或 ANTHROPIC_API_KEY),网络可用。 + * 不碰 4517 线上 daemon 与 ~/.maestro(数据目录走临时 MAESTRO_DATA_DIR)。 + * + * 用法:node scripts/poc-exec.mjs (在 maestro 仓库根目录) + */ +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const PORT = 4528; +const BASE = `http://127.0.0.1:${PORT}`; +const ORCH_INTERVAL = 3; // 秒 +const EXEC_TIMEOUT_MS = 8 * 60_000; + +const log = (m) => console.log(`[poc] ${m}`); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +async function api(method, path, body) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`); + return text ? JSON.parse(text) : null; +} + +async function main() { + // 0) 临时目录 + const dataDir = mkdtempSync(join(tmpdir(), 'maestro-poc-data-')); + const repo = mkdtempSync(join(tmpdir(), 'maestro-poc-repo-')); + + // 1) 临时 git repo(main + README) + git(repo, ['init', '-b', 'main']); + git(repo, ['config', 'user.name', 'maestro-poc']); + git(repo, ['config', 'user.email', 'poc@maestro.local']); + writeFileSync(join(repo, 'README.md'), '# poc demo\n'); + git(repo, ['add', '-A']); + git(repo, ['commit', '-m', 'init']); + log(`临时 repo:${repo}`); + log(`临时数据目录:${dataDir}`); + + // 2) 起临时 daemon(:4528,编排器 3s 一轮,定时同步关闭) + const daemon = spawn('npx', ['tsx', 'src/daemon/index.ts'], { + cwd: ROOT, + env: { + ...process.env, + MAESTRO_DATA_DIR: dataDir, + MAESTRO_PORT: String(PORT), + MAESTRO_ORCH_INTERVAL: String(ORCH_INTERVAL), + MAESTRO_SYNC_INTERVAL: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + daemon.stdout.on('data', (d) => process.stdout.write(`[daemon] ${d}`)); + daemon.stderr.on('data', (d) => process.stderr.write(`[daemon!] ${d}`)); + + const cleanup = (keep) => { + daemon.kill('SIGTERM'); + if (!keep) { + rmSync(dataDir, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + } else { + log(`保留现场:repo=${repo} dataDir=${dataDir}`); + } + }; + + try { + // 等 daemon 就绪 + let up = false; + for (let i = 0; i < 40; i++) { + try { await api('GET', '/api/projects'); up = true; break; } catch { await sleep(500); } + } + if (!up) throw new Error('daemon 未就绪(:4528)'); + log('daemon 就绪'); + + // 3) 注册项目(auto-easy)+ Easy 任务 + operations + const project = await api('POST', '/api/projects', { + name: 'poc', repoPath: repo, defaultBranch: 'main', autonomy: 'auto-easy', concurrency: 1, + }); + const task = await api('POST', `/api/projects/${project.id}/tasks`, { + title: '在 README.md 追加一行 hello maestro', complexity: 'easy', + }); + await api('POST', `/api/tasks/${task.id}/operations`, { + operations: '在仓库根目录的 README.md 末尾追加一行文本「hello maestro」。不要做其他任何改动。', + }); + log(`任务已建:${task.id}(ready,等编排器领取)`); + + // 4) 轮询直到 exec_review / needs_attention / 超时 + const t0 = Date.now(); + let cur = null; + for (;;) { + await sleep(3000); + cur = await api('GET', `/api/tasks/${task.id}`); + log(`任务状态:${cur.status}`); + if (cur.status === 'exec_review' || cur.status === 'needs_attention') break; + if (Date.now() - t0 > EXEC_TIMEOUT_MS) throw new Error(`超时(>${EXEC_TIMEOUT_MS / 1000}s),最后状态:${cur.status}`); + } + + if (cur.status !== 'exec_review') { + const runs = await api('GET', `/api/tasks/${task.id}/runs`); + throw new Error(`任务进入 ${cur.status}(非 exec_review)。runs=${JSON.stringify(runs, null, 2)}`); + } + + // 5) 断言:result 落库、分支有 commit、文件真的改了、main 未被动过 + const branch = `maestro/${task.id}`; + if (cur.result?.branch !== branch) throw new Error(`result.branch 异常:${JSON.stringify(cur.result)}`); + const commits = git(repo, ['log', '--format=%h %s', `main..${branch}`]).trim().split('\n').filter(Boolean); + if (commits.length < 1) throw new Error(`分支 ${branch} 上没有 commit`); + const readmeOnBranch = git(repo, ['show', `${branch}:README.md`]); + if (!readmeOnBranch.includes('hello maestro')) throw new Error(`分支上的 README 未包含「hello maestro」:\n${readmeOnBranch}`); + const readmeOnMain = readFileSync(join(repo, 'README.md'), 'utf8'); + if (readmeOnMain.includes('hello maestro')) throw new Error('main 的 README 被改了(不应自动合并)'); + if (cur.result.worktree && !existsSync(cur.result.worktree)) throw new Error('worktree 现场不存在'); + + log('================ PoC 通过 ✅ ================'); + log(`任务 → exec_review;分支 ${branch} 共 ${commits.length} 个 commit:`); + for (const c of commits) log(` ${c}`); + log(`diffSummary: ${cur.result.diffSummary}`); + log('main 未被自动合并(符合「绝不自动合并」)'); + cleanup(false); + process.exit(0); + } catch (e) { + console.error(`[poc] 失败:${e.message}`); + cleanup(true); + process.exit(1); + } +} + +main(); diff --git a/src/api/server.ts b/src/api/server.ts index e1f9823..6ea6691 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -6,6 +6,7 @@ import { isComplexity } from '../model/complexity.js'; import type { TaskStatus } from '../model/status.js'; import type { Project, Autonomy } from '../model/types.js'; import { syncProject, hasTodoJson } from '../sync/todo-sync.js'; +import { resolvedExecutorModels } from '../executor/models.js'; /** Project 出参:附加 hasTodoJson(/todo/todo.json 是否存在,每次序列化时算) */ function projectOut(p: Project): Project & { hasTodoJson: boolean } { @@ -88,6 +89,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance { autonomy: p.autonomy, concurrency: p.concurrency, status: p.status, + scheduling: 'score', // 调度模式:score 优先(自身分+链条惯性+解锁加权) + models: resolvedExecutorModels(p), // 各复杂度实际使用的执行模型 active: (byProject.get(p.id) ?? []).map((r) => ({ runId: r.runId, taskId: r.taskId, taskTitle: r.taskTitle, kind: r.kind, startedAt: r.startedAt, })), @@ -159,6 +162,12 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.listRuns(id); }); + // 单任务全量事件(升序):归档详情的状态流转时间线 + app.get('/api/tasks/:id/events', (req) => { + const { id } = req.params as { id: string }; + return store.listTaskEvents(id); + }); + app.post('/api/tasks/:id/plan', (req) => { const { id } = req.params as { id: string }; const b = req.body as { plan?: string }; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 765d5ab..47afc5f 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -4,6 +4,7 @@ import { Store } from '../store/index.js'; import { buildServer, attachWebSocket } from '../api/server.js'; import { registerStatic } from '../api/static.js'; import { loadConfig } from './config.js'; +import { startOrchestrator } from './orchestrator.js'; import { syncProject, todoJsonPath } from '../sync/todo-sync.js'; /** @@ -46,11 +47,12 @@ function startSyncLoop(store: Store, app: FastifyInstance): NodeJS.Timeout | nul return timer; } -/** maestrod:核心 daemon。Phase 1 = Store + REST/WS API(手动驱动;编排器在 Phase 2 接入)。 */ +/** maestrod:核心 daemon。Store + REST/WS API + 定时同步 + 编排器(Phase 2:自动领取可执行任务并在 worktree 起 headless CC)。 */ async function main(): Promise { const cfg = loadConfig(); const store = new Store(cfg.dbFile); const rec = store.reconcileDeps(); // 启动对账:ready↔blocked 按依赖纠正存量数据 + const itr = store.reconcileInterrupted(); // 中断恢复:上次退出时在跑的任务重新入队 const app = buildServer({ store, logger: true }); registerStatic(app); // Web 看板(web/ 静态文件) @@ -59,12 +61,15 @@ async function main(): Promise { await app.listen({ host: cfg.host, port: cfg.port }); app.log.info(`maestrod 就绪 · db=${cfg.dbFile} · http://${cfg.host}:${cfg.port} · ws ${cfg.host}:${cfg.port}/ws`); if (rec.blocked || rec.released) app.log.info(`依赖对账:转入等依赖 ${rec.blocked} · 放行可执行 ${rec.released}`); + if (itr.tasks) app.log.info(`中断恢复:${itr.tasks} 个执行中任务重新入队(${itr.runs} 个 run 标记中断)`); const syncTimer = startSyncLoop(store, app); + const orchTimer = startOrchestrator(store, app); // 编排器:自动领取可执行任务(MAESTRO_ORCH_INTERVAL 秒,0=关闭) const shutdown = async (): Promise => { app.log.info('收到退出信号,关闭中…'); if (syncTimer) clearInterval(syncTimer); + if (orchTimer) clearInterval(orchTimer); await app.close(); store.close(); process.exit(0); diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts new file mode 100644 index 0000000..25feded --- /dev/null +++ b/src/daemon/orchestrator.ts @@ -0,0 +1,224 @@ +import { Store } from '../store/index.js'; +import type { Project, Task, ReviewVerdict } from '../model/types.js'; +import { rankByScore } from '../model/scoring.js'; +import { createWorktree, worktreeDiff, type WorktreeDiff, type WorktreeInfo } from '../executor/worktree.js'; +import { runTask, type RunnerFn } from '../executor/runner.js'; +import { runVerify, type VerifyFn } from '../executor/verify.js'; +import { reviewTask, type ReviewerFn } from '../executor/reviewer.js'; +import { pickModel } from '../executor/models.js'; + +/** 失败后最多自动重试次数(重试 2 次 = 最多 3 次执行),之后 → needs_attention */ +export const MAX_RETRIES = 2; + +export interface OrchestratorLogger { + info(msg: string): void; + error(msg: string): void; +} + +/** 依赖注入点:测试传 mock,生产用真实现 */ +export interface OrchestratorDeps { + runner: RunnerFn; + reviewer: ReviewerFn; + verify: VerifyFn; + createWorktree: (repoPath: string, taskId: string, baseBranch: string) => Promise; + worktreeDiff: (repoPath: string, dir: string, branch: string, baseBranch: string) => Promise; +} + +export interface Orchestrator { + /** 跑一轮领取(同步领取 + 异步执行,不阻塞)。错误只记日志。 */ + tick(): void; + /** 等待所有在途执行收尾(测试用) */ + drain(): Promise; + /** 在途任务 id(防同任务重复领取) */ + readonly inflight: ReadonlyMap; +} + +/** + * 编排器核心(与定时器解耦,便于测试)。 + * 每轮对 status=active 且 autonomy≠manual 的项目:在途数 < concurrency 时领任务—— + * ready 叶子(deps 全 done;auto-easy 只领 easy)或 queued(重试/重启遗留)。 + * 成功 → setResult + exec_review;失败 → failed → 重试 ≤MAX_RETRIES 次 → needs_attention。 + */ +export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: Partial = {}): Orchestrator { + const d: OrchestratorDeps = { runner: runTask, reviewer: reviewTask, verify: runVerify, createWorktree, worktreeDiff, ...deps }; + const inflight = new Map(); // taskId → projectId + const pending = new Set>(); + + /** + * 本项目可领取的任务:queued(重试/孤儿)+ ready 叶子且 deps 全 done;auto-easy 只挑 easy。 + * 按调度分降序返回(score = 自身分 + 已完成依赖分 + 等待解锁的 blocked 任务分,见 model/scoring.ts)。 + */ + function claimable(project: Project): Array<{ task: Task; score: number }> { + const tasks = store.listTasks(project.id); + const byId = new Map(tasks.map((t) => [t.id, t])); + const parents = new Set(tasks.filter((t) => t.parentId).map((t) => t.parentId as string)); + const easyOnly = project.autonomy === 'auto-easy'; + const candidates = tasks.filter((t) => { + if (inflight.has(t.id)) return false; + if (t.status !== 'ready' && t.status !== 'queued') return false; + if (parents.has(t.id)) return false; // 非叶子(容器)跳过 + if (easyOnly && t.complexity !== 'easy') return false; + return t.deps.every((dep) => byId.get(dep)?.status === 'done'); + }); + return rankByScore(candidates, tasks); + } + + /** 单任务全流程:executing → worktree → run → verify → reviewer(复审,失败不挡) → setResult(summary/verdict) → exec_review / failed(重试) */ + async function executeTask(project: Project, task: Task): Promise { + let runId: string | null = null; + let runClosed = false; + try { + store.transition(task.id, 'executing', { by: 'orchestrator' }); + const wt = await d.createWorktree(project.repoPath, task.id, project.defaultBranch); + const run = store.startRun(task.id, 'executor', { worktree: wt.dir, branch: wt.branch }); + runId = run.id; + log.info(`执行任务 ${task.id}「${task.title}」 run=${run.id} worktree=${wt.dir}`); + + const rr = await d.runner(task, project, wt, run.id); + if (!rr.ok) { + store.finishRun(run.id, 'failed', { + error: rr.error ?? '执行失败', + transcriptRef: rr.transcriptRef ?? undefined, + claudeSessionId: rr.sessionId ?? undefined, + }); + runClosed = true; + throw new Error(rr.error ?? '执行失败'); + } + + const vr = await d.verify(project, wt.dir, run.id); + if (!vr.ok) { + store.finishRun(run.id, 'failed', { + error: vr.error ?? 'verify 失败', + transcriptRef: rr.transcriptRef ?? undefined, + claudeSessionId: rr.sessionId ?? undefined, + }); + runClosed = true; + throw new Error(vr.error ?? 'verify 失败'); + } + + const diff = await d.worktreeDiff(project.repoPath, wt.dir, wt.branch, project.defaultBranch); + + // 自动复审(kind=reviewer 的新 run):失败不挡任务,summary 记失败原因、verdict=null + let summary: string | null = null; + let verdict: ReviewVerdict | null = null; + let reviewRunId: string | null = null; + try { + const review = store.startRun(task.id, 'reviewer', { worktree: wt.dir, branch: wt.branch }); + reviewRunId = review.id; + log.info(`复审任务 ${task.id} run=${review.id} model=${pickModel(task, project, 'reviewer')}`); + const rv = await d.reviewer(task, project, wt, review.id, rr.finalText ?? ''); + summary = rv.summary; + verdict = rv.verdict; + store.finishRun(review.id, 'succeeded', { + transcriptRef: rv.transcriptRef ?? undefined, + claudeSessionId: rv.sessionId ?? undefined, + }); + log.info(`任务 ${task.id} 复审完成 verdict=${verdict ?? '(未解析到)'}`); + } catch (e) { + const reMsg = (e as Error).message; + summary = `自动复审失败:${reMsg}`; + verdict = null; + if (reviewRunId) { + try { store.finishRun(reviewRunId, 'failed', { error: reMsg }); } catch { /* 收尾失败不影响主流程 */ } + } + log.error(`任务 ${task.id} 复审失败(不挡任务,照常进 exec_review):${reMsg}`); + } + + store.setResult(task.id, { + branch: wt.branch, worktree: wt.dir, + diffSummary: diff.diffSummary, commits: diff.commits, prUrl: null, + summary, verdict, + }); + store.transition(task.id, 'exec_review', { by: 'orchestrator', runId: run.id }); + store.finishRun(run.id, 'succeeded', { + transcriptRef: rr.transcriptRef ?? undefined, + claudeSessionId: rr.sessionId ?? undefined, + }); + log.info(`任务 ${task.id} 执行完成 → exec_review(${diff.commits.length} commits)`); + } catch (e) { + const msg = (e as Error).message; + log.error(`任务 ${task.id} 执行失败:${msg}`); + try { + if (runId && !runClosed) { + store.finishRun(runId, 'failed', { error: msg }); // worktree 创建后抛错(diff 等)时收尾 + } else if (!runId) { + // run 还没建(如 createWorktree 失败):补记一条 failed run,保证重试计数不漏 + const r = store.startRun(task.id, 'executor'); + store.finishRun(r.id, 'failed', { error: msg }); + } + store.transition(task.id, 'failed', { by: 'orchestrator', error: msg }); + const failedRuns = store.listRuns(task.id).filter((r) => r.kind === 'executor' && r.status === 'failed').length; + const priorFailed = Math.max(0, failedRuns - 1); // 不含本次 + if (priorFailed < MAX_RETRIES) { + store.transition(task.id, 'queued', { by: 'orchestrator', retry: priorFailed + 1 }); + log.info(`任务 ${task.id} 重新入队(第 ${priorFailed + 1} 次重试,下一轮领取)`); + } else { + store.transition(task.id, 'needs_attention', { by: 'orchestrator', failedRuns }); + log.error(`任务 ${task.id} 连续失败 ${failedRuns} 次 → needs_attention`); + } + } catch (e2) { + log.error(`任务 ${task.id} 失败收尾出错:${(e2 as Error).message}`); + } + } + } + + function tick(): void { + try { + for (const p of store.listProjects()) { + if (p.status !== 'active' || p.autonomy === 'manual') continue; + let active = 0; + for (const pid of inflight.values()) if (pid === p.id) active++; + if (active >= p.concurrency) continue; + + for (const { task, score } of claimable(p)) { + if (active >= p.concurrency) break; + try { + if (task.status === 'ready') store.transition(task.id, 'queued', { by: 'orchestrator', score }); + } catch (e) { + log.error(`任务 ${task.id} 入队失败:${(e as Error).message}`); + continue; + } + log.info(`领取任务 ${task.id}「${task.title}」score=${score} model=${pickModel(task, p, 'executor')}`); + inflight.set(task.id, p.id); + active++; + const job: Promise = executeTask(p, { ...task, status: 'queued' }) + .catch((e) => log.error(`任务 ${task.id} 执行异常:${(e as Error).message}`)) + .finally(() => { + inflight.delete(task.id); + pending.delete(job); + }); + pending.add(job); + } + } + } catch (e) { + log.error(`编排器轮询失败:${(e as Error).message}`); + } + } + + async function drain(): Promise { + while (pending.size > 0) await Promise.allSettled([...pending]); + } + + return { tick, drain, inflight }; +} + +/** + * 接线入口:MAESTRO_ORCH_INTERVAL(秒)控制轮询间隔,默认 15,0=关闭。 + * 返回 timer 供 shutdown 时 clearInterval。 + */ +export function startOrchestrator( + store: Store, + app: { log: OrchestratorLogger }, + deps: Partial = {}, +): NodeJS.Timeout | null { + const intervalSec = Number(process.env.MAESTRO_ORCH_INTERVAL ?? 15); + if (!Number.isFinite(intervalSec) || intervalSec <= 0) { + app.log.info('编排器已关闭(MAESTRO_ORCH_INTERVAL=0)'); + return null; + } + const orch = createOrchestrator(store, app.log, deps); + const timer = setInterval(() => orch.tick(), intervalSec * 1000); + timer.unref(); + app.log.info(`编排器已启用:每 ${intervalSec}s 一轮领取(autonomy≠manual 的 active 项目)`); + return timer; +} diff --git a/src/executor/cc.ts b/src/executor/cc.ts new file mode 100644 index 0000000..9159295 --- /dev/null +++ b/src/executor/cc.ts @@ -0,0 +1,132 @@ +import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { isModelError, pickFallbackModel } from './models.js'; + +/** 转录目录:/transcripts */ +export function transcriptDir(): string { + return join(process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'), 'transcripts'); +} + +export interface CCOptions { + prompt: string; + cwd: string; + model: string; + runId: string; + maxTurns: number; + timeoutMs: number; + allowedTools: string[]; + permissionMode?: 'acceptEdits' | 'default'; +} + +export interface CCResult { + ok: boolean; + finalText: string; // SDK result 消息的文本(失败时为 '') + sessionId: string | null; + transcriptRef: string; + modelUsed: string; // 实际使用的模型(发生回退时为回退模型) + fellBack: boolean; + error?: string; +} + +interface AttemptResult { + ok: boolean; + finalText: string; + sessionId: string | null; + error?: string; +} + +/** 单次 headless CC 会话:流式消息逐行写 transcript,不抛错(失败折叠进 error) */ +async function attempt(opts: CCOptions, model: string, out: WriteStream): Promise { + let sessionId: string | null = null; + let finalText = ''; + let resultOk = false; + let resultError: string | undefined; + let sawResult = false; + + const abort = new AbortController(); + const killer = setTimeout(() => abort.abort(new Error('执行超时')), opts.timeoutMs); + + try { + const q = query({ + prompt: opts.prompt, + options: { + cwd: opts.cwd, + permissionMode: opts.permissionMode ?? 'default', + maxTurns: opts.maxTurns, + settingSources: ['project'], // 读项目 CLAUDE.md / settings,不读用户全局 + abortController: abort, + model, + allowedTools: opts.allowedTools, + }, + }); + + for await (const message of q) { + out.write(`${JSON.stringify(message)}\n`); + const sid = (message as { session_id?: unknown }).session_id; + if (typeof sid === 'string' && sid) sessionId = sid; + if (message.type === 'result') { + sawResult = true; + if (message.subtype === 'success' && !message.is_error) { + resultOk = true; + const txt = (message as { result?: unknown }).result; + if (typeof txt === 'string') finalText = txt; + } else { + const errs = 'errors' in message && Array.isArray(message.errors) ? message.errors.join('; ') : ''; + resultError = `Claude Code 结束于 ${message.subtype}${errs ? `:${errs}` : ''}`; + } + } + } + if (!sawResult && !resultError) resultError = '未收到 result 消息(会话异常结束)'; + } catch (e) { + resultError = `Claude Code 执行异常:${(e as Error).message}`; + resultOk = false; + } finally { + clearTimeout(killer); + } + + if (!resultOk) return { ok: false, finalText: '', sessionId, error: resultError ?? '未知错误' }; + return { ok: true, finalText, sessionId }; +} + +/** + * 跑一次 headless CC(runner / reviewer 公用)。 + * 模型可用性兜底:失败且错误信息像模型不可用(not_found/invalid/permission 等)时, + * 自动用回退链取一个 ≠ 原模型的模型在同一 run 内重试一次;回退记入 transcript 与 finalText。 + */ +export async function runClaude(opts: CCOptions): Promise { + const dir = transcriptDir(); + mkdirSync(dir, { recursive: true }); + const transcriptRef = join(dir, `${opts.runId}.jsonl`); + const out = createWriteStream(transcriptRef, { flags: 'a' }); + + try { + let r = await attempt(opts, opts.model, out); + let modelUsed = opts.model; + let fellBack = false; + + if (!r.ok && r.error && isModelError(r.error)) { + const fb = pickFallbackModel(opts.model); + if (fb) { + out.write(`${JSON.stringify({ type: 'maestro.model_fallback', from: opts.model, to: fb, reason: r.error })}\n`); + r = await attempt(opts, fb, out); + modelUsed = fb; + fellBack = true; + } + } + + const note = fellBack && r.ok ? `\n\n[模型回退] 原模型 ${opts.model} 不可用,实际使用 ${modelUsed}` : ''; + return { + ok: r.ok, + finalText: r.finalText + note, + sessionId: r.sessionId, + transcriptRef, + modelUsed, + fellBack, + ...(r.error ? { error: fellBack ? `${r.error}(已回退至 ${modelUsed} 重试)` : r.error } : {}), + }; + } finally { + await new Promise((resolve) => out.end(resolve)); + } +} diff --git a/src/executor/models.ts b/src/executor/models.ts new file mode 100644 index 0000000..c372a6d --- /dev/null +++ b/src/executor/models.ts @@ -0,0 +1,60 @@ +import type { Project, Task } from '../model/types.js'; +import type { Complexity } from '../model/complexity.js'; + +/** 角色:executor(执行改动)/ reviewer(执行后复审) */ +export type ModelRole = 'executor' | 'reviewer'; + +/** 模型不可用时的回退链(按序取第一个 ≠ 失败模型的,同一 run 内只重试一次) */ +export const MODEL_FALLBACK_CHAIN = ['claude-opus-4-8', 'claude-sonnet-4-6'] as const; + +/** 复杂度 → [env 覆盖变量名, 默认模型] */ +const MODEL_TABLE: Record> = { + executor: { + easy: ['MAESTRO_MODEL_EASY', 'claude-sonnet-4-6'], + medium: ['MAESTRO_MODEL_MEDIUM', 'claude-opus-4-8'], + hard: ['MAESTRO_MODEL_HARD', 'claude-fable-5'], + }, + reviewer: { + easy: ['MAESTRO_MODEL_REVIEW_EASY', 'claude-sonnet-4-6'], + medium: ['MAESTRO_MODEL_REVIEW_MEDIUM', 'claude-opus-4-8'], + hard: ['MAESTRO_MODEL_REVIEW_HARD', 'claude-opus-4-8'], + }, +}; + +/** + * 按角色 + 复杂度选模型: + * 1. project.model 设了就最优先(executor/reviewer 都用它) + * 2. 否则查 env 覆盖(MAESTRO_MODEL_* / MAESTRO_MODEL_REVIEW_*) + * 3. 否则按复杂度取默认 + */ +export function pickModel(task: Task, project: Project, role: ModelRole): string { + if (project.model) return project.model; + const [env, fallback] = MODEL_TABLE[role][task.complexity]; + const override = process.env[env]?.trim(); + return override || fallback; +} + +/** 看板展示用:当前项目 executor 各复杂度实际会用的模型(含 project.model / env 覆盖解析) */ +export function resolvedExecutorModels(project: Project): Record { + const out = {} as Record; + for (const c of ['easy', 'medium', 'hard'] as Complexity[]) { + if (project.model) { out[c] = project.model; continue; } + const [env, fallback] = MODEL_TABLE.executor[c]; + out[c] = process.env[env]?.trim() || fallback; + } + return out; +} + +/** 错误信息是否像“模型不可用”(not_found / invalid / permission 等模式 + 提到 model) */ +export function isModelError(msg: string): boolean { + if (!/model/i.test(msg)) return false; + return /not[_\s-]?found|invalid|permission|forbidden|unauthorized|unavailable|unknown|unsupported|does not exist|no access|404|403/i.test(msg); +} + +/** 回退链里取第一个与失败模型不同的;链上全相同(不可能两项都等)则 null */ +export function pickFallbackModel(failedModel: string): string | null { + for (const m of MODEL_FALLBACK_CHAIN) { + if (m !== failedModel) return m; + } + return null; +} diff --git a/src/executor/reviewer.ts b/src/executor/reviewer.ts new file mode 100644 index 0000000..4f49bc9 --- /dev/null +++ b/src/executor/reviewer.ts @@ -0,0 +1,111 @@ +import type { Project, Task, ReviewVerdict } from '../model/types.js'; +import type { WorktreeInfo } from './worktree.js'; +import { runClaude } from './cc.js'; +import { pickModel } from './models.js'; + +export interface ReviewResult { + summary: string; // 最终文本去掉 VERDICT 行(markdown) + verdict: ReviewVerdict | null; // 解析不到 = null + transcriptRef: string | null; + sessionId: string | null; +} + +/** 复审函数签名(orchestrator 依赖注入点;测试传 mock,生产传 reviewTask)。失败时抛错,由 orchestrator 兜底。 */ +export type ReviewerFn = ( + task: Task, + project: Project, + worktree: WorktreeInfo, + runId: string, + executorReport: string, +) => Promise; + +const REVIEW_MAX_TURNS = 40; +const REVIEW_TIMEOUT_MS = 15 * 60_000; // 15min + +/** 组装复审提示词:任务说明 + 执行者自述 + 审 diff + 固定模板输出 */ +export function buildReviewPrompt(task: Task, project: Project, worktree: WorktreeInfo, executorReport: string): string { + const body = task.operations ?? task.spec ?? task.plan ?? ''; + return [ + `# 复审任务:${task.title}`, + `任务 ID:${task.id}`, + '', + '你是独立的代码复审员。一个执行 agent 刚在当前 git worktree 完成了下述任务,请你复审它的改动。', + '', + '## 任务原始要求', + body || '(无详细说明,以标题为准)', + '', + '## 执行者自述', + executorReport.trim() || '(执行者未留下自述)', + '', + '## 复审要求', + `- 用 \`git diff ${project.defaultBranch}...${worktree.branch}\` 查看全部改动,结合 git log / git show 与源码阅读核对。`, + '- 你只有只读权限:不得修改任何文件,不得执行除 git diff/log/show/status 之外的命令。', + '- 核对执行者自述与实际 diff 是否一致,注意有没有声称做了但实际没做的内容。', + '', + '## 输出格式(最终回复必须严格按此模板,markdown)', + '```', + '## 做了什么', + '## 怎么做的', + '## 测试情况(有没有测试、跑了没有、结果如何;无测试体系则说明为什么不适用)', + '## Code Review(正确性/可维护性问题,逐条带文件:行号)', + '## 安全 Review(凭证泄露/注入/权限/危险操作;本项目还需检查 UI 文案红线词与生产部署安全)', + '## 结论', + '(建议通过 或 建议拒绝;拒绝必须给出理由与改进方案)', + 'VERDICT: approve|reject', + '```', + '- 最后一行必须是单独一行 `VERDICT: approve` 或 `VERDICT: reject`(机器解析用,不要带其他内容)。', + ].join('\n'); +} + +/** + * 从复审最终文本解析 VERDICT 行: + * - verdict = 最后一个匹配 `VERDICT: approve|reject` 的行(解析不到 = null) + * - summary = 去掉所有 VERDICT 行后的文本(trim) + */ +export function parseVerdict(text: string): { summary: string; verdict: ReviewVerdict | null } { + let verdict: ReviewVerdict | null = null; + const kept: string[] = []; + for (const line of text.split('\n')) { + const m = line.match(/^\s*VERDICT:\s*(approve|reject)\s*$/i); + if (m) { + verdict = m[1].toLowerCase() as ReviewVerdict; + continue; + } + kept.push(line); + } + return { summary: kept.join('\n').trim(), verdict }; +} + +/** + * 执行后自动复审:在 worktree 内再起一个只读 headless CC,审 diff 并产出固定模板 summary + verdict。 + * 转录写 /.jsonl。失败抛错(orchestrator 兜底,不挡任务)。 + */ +export async function reviewTask( + task: Task, + project: Project, + worktree: WorktreeInfo, + runId: string, + executorReport: string, +): Promise { + const model = pickModel(task, project, 'reviewer'); + const cc = await runClaude({ + prompt: buildReviewPrompt(task, project, worktree, executorReport), + cwd: worktree.dir, + model, + runId, + maxTurns: REVIEW_MAX_TURNS, + timeoutMs: REVIEW_TIMEOUT_MS, + permissionMode: 'default', + // 只读白名单:可读文件 + 仅 git 只读命令,不可改文件 + allowedTools: [ + 'Read', 'Grep', 'Glob', + 'Bash(git diff:*)', 'Bash(git log:*)', 'Bash(git show:*)', 'Bash(git status:*)', + ], + }); + + if (!cc.ok) throw new Error(cc.error ?? '复审执行失败'); + if (!cc.finalText.trim()) throw new Error('复审未产出最终文本'); + + const { summary, verdict } = parseVerdict(cc.finalText); + return { summary, verdict, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId }; +} diff --git a/src/executor/runner.ts b/src/executor/runner.ts new file mode 100644 index 0000000..41fc8e4 --- /dev/null +++ b/src/executor/runner.ts @@ -0,0 +1,94 @@ +import type { Project, Task } from '../model/types.js'; +import { git, type WorktreeInfo } from './worktree.js'; +import { runClaude } from './cc.js'; +import { pickModel } from './models.js'; + +export { transcriptDir } from './cc.js'; + +export interface RunnerResult { + ok: boolean; + transcriptRef: string | null; + sessionId: string | null; + /** SDK result 消息的文本(执行者自述,传给 reviewer);mock/失败时可缺省 */ + finalText?: string | null; + /** 实际使用的模型(发生回退时为回退模型) */ + modelUsed?: string; + error?: string; +} + +/** 执行函数签名(orchestrator 依赖注入点;测试传 mock,生产传 runTask) */ +export type RunnerFn = (task: Task, project: Project, worktree: WorktreeInfo, runId: string) => Promise; + +const DEFAULT_MAX_TURNS = 100; +const DEFAULT_TIMEOUT_MS = 30 * 60_000; // 整体兜底超时 30min + +/** 组装任务提示词:标题 + operations/spec 全文 + 执行约束 */ +export function buildPrompt(task: Task): string { + const body = task.operations ?? task.spec ?? task.plan ?? ''; + return [ + `# 任务:${task.title}`, + `任务 ID:${task.id}`, + '', + '## 任务内容', + body || '(无详细说明,按标题完成)', + '', + '## 执行约束(必须遵守)', + '- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。', + `- 完成后用 \`git add -A && git commit\` 提交全部改动,commit message 必须包含任务 ID「${task.id}」。`, + '- 不得执行 git push / git merge / 切换分支,当前分支就是你的工作分支。', + '- 不要发布、部署或执行任何有外部副作用的操作。', + '- 若项目有测试体系且改动可测试,请补充并实际运行相关测试(npm/go/pytest/shellcheck 等已授权),并在最终回复中说明测试命令与结果。', + '- 最终回复请简要总结:做了什么、怎么做的、测试结果。', + ].join('\n'); +} + +/** CC 没提交时由 runner 兜底:git add -A + commit(无改动则跳过) */ +async function ensureCommitted(dir: string, task: Task): Promise { + const status = (await git(dir, ['status', '--porcelain'])).trim(); + if (!status) return; + await git(dir, ['add', '-A']); + await git(dir, ['-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'commit', '-m', `maestro(${task.id}): ${task.title}`]); +} + +/** + * 在 worktree 内用 Claude Agent SDK 起 headless Claude Code 执行任务。 + * 模型按复杂度选择(project.model 最优先),不可用时自动回退重试(见 cc.ts / models.ts)。 + * 流式消息逐行写入 /.jsonl;返回 ok/transcriptRef/sessionId/finalText/error。 + * 不抛错:一切失败都折叠进 { ok: false, error }。 + */ +export async function runTask(task: Task, project: Project, worktree: WorktreeInfo, runId: string): Promise { + const model = pickModel(task, project, 'executor'); + const cc = await runClaude({ + prompt: buildPrompt(task), + cwd: worktree.dir, + model, + runId, + maxTurns: DEFAULT_MAX_TURNS, + timeoutMs: DEFAULT_TIMEOUT_MS, + permissionMode: 'acceptEdits', // worktree 内自动接受编辑 + allowedTools: [ + 'Read', 'Edit', 'Write', 'Glob', 'Grep', + // git:提交所需最小面(不含 push) + 'Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git log:*)', + 'Bash(git add:*)', 'Bash(git commit:*)', + // 测试/构建:让执行任务自己跑测试(仍关在 worktree 内,无 push 无包发布) + 'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(npm ci:*)', 'Bash(npm install:*)', + 'Bash(npx:*)', 'Bash(node:*)', + 'Bash(go build:*)', 'Bash(go test:*)', 'Bash(go vet:*)', 'Bash(go mod:*)', 'Bash(go run:*)', + 'Bash(shellcheck:*)', 'Bash(bash -n:*)', 'Bash(make:*)', + 'Bash(python3 -m pytest:*)', 'Bash(pytest:*)', + ], + }); + + if (!cc.ok) { + return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, error: cc.error ?? '未知错误' }; + } + + // 兜底:CC 没 commit 时由 runner 代为提交 + try { + await ensureCommitted(worktree.dir, task); + } catch (e) { + return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, error: `兜底提交失败:${(e as Error).message}` }; + } + return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed }; +} diff --git a/src/executor/verify.ts b/src/executor/verify.ts new file mode 100644 index 0000000..963a2bc --- /dev/null +++ b/src/executor/verify.ts @@ -0,0 +1,49 @@ +import { execFile } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Project } from '../model/types.js'; +import { transcriptDir } from './runner.js'; + +export interface VerifyResult { + ok: boolean; + exitCode: number | null; + logRef: string | null; // /.verify.log;无 verifyCmd 时为 null + error?: string; +} + +/** 校验函数签名(orchestrator 依赖注入点) */ +export type VerifyFn = (project: Project, dir: string, runId: string) => Promise; + +const VERIFY_TIMEOUT_MS = 10 * 60_000; // 10min + +/** + * project.verifyCmd 存在则在 worktree 内以 shell 执行;exit 0 = 通过。 + * stdout/stderr 写 /.verify.log。无 verifyCmd 视为通过。 + */ +export async function runVerify(project: Project, dir: string, runId: string): Promise { + const cmd = project.verifyCmd?.trim(); + if (!cmd) return { ok: true, exitCode: null, logRef: null }; + + const logDir = transcriptDir(); + mkdirSync(logDir, { recursive: true }); + const logRef = join(logDir, `${runId}.verify.log`); + + return new Promise((resolve) => { + execFile('/bin/sh', ['-c', cmd], { cwd: dir, timeout: VERIFY_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + const rawCode = err ? (err as { code?: unknown }).code : 0; + const numericExit = typeof rawCode === 'number' ? rawCode : (err ? 1 : 0); + writeFileSync(logRef, `$ ${cmd}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n--- exit: ${numericExit} ---\n`); + if (!err) { + resolve({ ok: true, exitCode: 0, logRef }); + } else { + const timedOut = (err as { killed?: boolean }).killed === true; + resolve({ + ok: false, + exitCode: numericExit, + logRef, + error: timedOut ? `verify 超时(>${VERIFY_TIMEOUT_MS / 1000}s):${cmd}` : `verify 失败(exit ${numericExit}):${cmd}`, + }); + } + }); + }); +} diff --git a/src/executor/worktree.ts b/src/executor/worktree.ts new file mode 100644 index 0000000..e89ccdd --- /dev/null +++ b/src/executor/worktree.ts @@ -0,0 +1,86 @@ +import { execFile } from 'node:child_process'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { mkdirSync, rmSync } from 'node:fs'; + +const GIT_TIMEOUT_MS = 60_000; +const MAX_BUFFER = 16 * 1024 * 1024; + +/** 在 repoPath 下执行 git(execFile 不走 shell),超时与错误透传(附 stderr)。 */ +export function git(repoPath: string, args: string[], timeoutMs = GIT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd: repoPath, timeout: timeoutMs, maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => { + if (err) { + reject(new Error(`git ${args.join(' ')} 失败: ${String(stderr).trim() || err.message}`)); + } else { + resolve(stdout); + } + }); + }); +} + +export interface WorktreeInfo { + dir: string; + branch: string; +} + +export interface WorktreeDiff { + diffSummary: string; + commits: string[]; +} + +/** worktree 根目录:/worktrees(调用时读 env,便于测试/PoC 隔离) */ +export function worktreeBase(): string { + return join(process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'), 'worktrees'); +} + +/** 任务分支名(确定性,可在创建前预知) */ +export function branchFor(taskId: string): string { + return `maestro/${taskId}`; +} + +/** 任务 worktree 目录(确定性):/// */ +export function worktreeDirFor(repoPath: string, taskId: string): string { + return join(worktreeBase(), basename(repoPath), taskId); +} + +/** + * 为任务创建隔离 worktree:分支 maestro/,基于 baseBranch。 + * 残留的同名 worktree/分支先清理再重建(重试场景从干净的 baseBranch 重新开始)。 + */ +export async function createWorktree(repoPath: string, taskId: string, baseBranch: string): Promise { + const branch = branchFor(taskId); + const dir = worktreeDirFor(repoPath, taskId); + + // 清理残留:已注册的同路径 worktree → 强制移除;目录残骸 → 删除;旧分支 → 删除 + await git(repoPath, ['worktree', 'prune']).catch(() => undefined); + await git(repoPath, ['worktree', 'remove', '--force', dir]).catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + await git(repoPath, ['branch', '-D', branch]).catch(() => undefined); + + mkdirSync(dirname(dir), { recursive: true }); + await git(repoPath, ['worktree', 'add', '-b', branch, dir, baseBranch]); + return { dir, branch }; +} + +/** 分支相对 baseBranch 的改动:diff --stat 摘要 + commit 列表(新→旧,"<短hash> <标题>") */ +export async function worktreeDiff( + repoPath: string, + _dir: string, + branch: string, + baseBranch: string, +): Promise { + const diffSummary = (await git(repoPath, ['diff', '--stat', `${baseBranch}...${branch}`])).trim(); + const log = (await git(repoPath, ['log', '--format=%h %s', `${baseBranch}..${branch}`])).trim(); + const commits = log ? log.split('\n').map((l) => l.trim()).filter(Boolean) : []; + return { diffSummary, commits }; +} + +/** + * 移除 worktree(目录 + 注册项)。分支保留(合并后由人/后续清理处理)。 + * 仅在成功合并后调用;执行失败时保留现场,不要调它。 + */ +export async function removeWorktree(repoPath: string, dir: string): Promise { + await git(repoPath, ['worktree', 'remove', '--force', dir]); + await git(repoPath, ['worktree', 'prune']).catch(() => undefined); +} diff --git a/src/model/scoring.ts b/src/model/scoring.ts new file mode 100644 index 0000000..b543067 --- /dev/null +++ b/src/model/scoring.ts @@ -0,0 +1,47 @@ +import type { Task } from './types.js'; + +/** + * 调度打分(在领取那一刻现算,不落库): + * score = 自身分 + Σ(已完成依赖的自身分) —— 链条惯性:前置投入越重越优先出活 + * + Σ(直接等我解锁的 blocked 任务的自身分) —— 解锁效应:等我的人越多越优先 + * 自身分按优先级:P0=3 / P1=2 / P2=1。 + * 依赖与解锁均只算一层(不递归):deps 在创建时即固定且只能引用已存在任务, + * 因此依赖图天然无环,但递归会让深链分数膨胀失控,故不递归。 + */ +export function baseScore(priority: number): number { + return Math.max(1, 3 - priority); +} + +/** 反向索引:taskId → 直接依赖它的任务列表 */ +export function buildDependentsIndex(tasks: Task[]): Map { + const idx = new Map(); + for (const t of tasks) { + for (const d of t.deps) { + const list = idx.get(d) ?? []; + list.push(t); + idx.set(d, list); + } + } + return idx; +} + +export function scoreTask(t: Task, byId: Map, dependents: Map): number { + let s = baseScore(t.priority); + for (const d of t.deps) { + const dt = byId.get(d); + if (dt?.status === 'done') s += baseScore(dt.priority); // 链条惯性 + } + for (const w of dependents.get(t.id) ?? []) { + if (w.status === 'blocked') s += baseScore(w.priority); // 解锁效应 + } + return s; +} + +/** 把候选任务按 score 降序排(同分创建早的在前)。返回新数组,附带分数。 */ +export function rankByScore(candidates: Task[], all: Task[]): Array<{ task: Task; score: number }> { + const byId = new Map(all.map((t) => [t.id, t])); + const dependents = buildDependentsIndex(all); + return candidates + .map((task) => ({ task, score: scoreTask(task, byId, dependents) })) + .sort((a, b) => b.score - a.score || a.task.createdAt.localeCompare(b.task.createdAt)); +} diff --git a/src/model/status.ts b/src/model/status.ts index 37b4e5d..68411cd 100644 --- a/src/model/status.ts +++ b/src/model/status.ts @@ -27,7 +27,7 @@ export const STATUS_LABEL: Record = { speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', - blocked: '被依赖阻塞', + blocked: '被阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', diff --git a/src/model/types.ts b/src/model/types.ts index 82d6f9d..72f13f8 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -32,12 +32,17 @@ export interface ApprovalRecord { at: string; } +/** 复审结论:approve=建议通过 / reject=建议拒绝 / null=未复审(复审失败或旧数据) */ +export type ReviewVerdict = 'approve' | 'reject'; + export interface TaskResult { branch: string | null; worktree: string | null; diffSummary: string | null; commits: string[]; prUrl: string | null; + summary: string | null; // 复审 summary(markdown),旧数据缺省 null + verdict: ReviewVerdict | null; // 复审结论,解析不到 / 复审失败 = null } export interface Task { @@ -61,13 +66,13 @@ export interface Task { updatedAt: string; } -export type RunKind = 'planner' | 'executor'; +export type RunKind = 'planner' | 'executor' | 'reviewer'; export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled'; export interface Run { id: Id; taskId: Id; - kind: RunKind; // planner(产出拆解/方案)| executor(执行改动) + kind: RunKind; // planner(产出拆解/方案)| executor(执行改动)| reviewer(执行后自动复审) worktree: string | null; branch: string | null; status: RunStatus; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index 63963cd..1f8036d 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -39,13 +39,27 @@ export function rowToProject(r: ProjectRow): Project { }; } +/** 旧 result JSON 兜底:summary/verdict 是后加字段,老数据缺省补 null */ +function parseResult(json: string): TaskResult { + const raw = JSON.parse(json) as Partial; + return { + branch: raw.branch ?? null, + worktree: raw.worktree ?? null, + diffSummary: raw.diffSummary ?? null, + commits: raw.commits ?? [], + prUrl: raw.prUrl ?? null, + summary: raw.summary ?? null, + verdict: raw.verdict === 'approve' || raw.verdict === 'reject' ? raw.verdict : null, + }; +} + export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task { return { id: r.id, projectId: r.project_id, parentId: r.parent_id, depth: r.depth, title: r.title, complexity: r.complexity as Complexity, status: r.status as TaskStatus, priority: r.priority, deps: JSON.parse(r.deps) as string[], plan: r.plan, spec: r.spec, operations: r.operations, - approvals, result: r.result ? (JSON.parse(r.result) as TaskResult) : null, + approvals, result: r.result ? parseResult(r.result) : null, assignee: r.assignee as Task['assignee'], createdAt: r.created_at, updatedAt: r.updated_at, }; } diff --git a/src/store/store.ts b/src/store/store.ts index 76aed9e..0f74ccb 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -7,6 +7,7 @@ import { import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js'; import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js'; import type { Complexity } from '../model/complexity.js'; +import { rankByScore } from '../model/scoring.js'; import { type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL, } from '../model/status.js'; @@ -174,9 +175,15 @@ export class Store { } if (input.priority !== undefined) assertPriority(input.priority); + // deps 必须引用本项目已存在的任务(也因此依赖图天然无环:新任务不可能已被引用) + const depsArr = input.deps ?? []; + for (const d of depsArr) { + const dep = this.getTaskRow(d); + if (!dep) throw new StoreError(`依赖任务不存在: ${d}`); + if (dep.project_id !== input.projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`); + } let status = initialNextStatus(input.complexity); // Easy 直达 ready,但有未完成依赖时落位 blocked - const depsArr = input.deps ?? []; if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) { status = 'blocked'; } @@ -333,6 +340,26 @@ export class Store { } } + /** + * 任务变 done 后的连锁处理: + * 1. 放行依赖它的 blocked 任务; + * 2. 已拆解容器的子任务全部 done/取消 → 容器自动 done(“深度1整树完成”生命周期闭环),逐级向上。 + */ + private afterDone(projectId: string, taskId: string): void { + this.releaseDependents(projectId); + let cur = this.getTaskRow(taskId); + while (cur?.parent_id) { + const parent = this.getTaskRow(cur.parent_id); + if (!parent || parent.status !== 'decomposed') break; + const kids = this.db.prepare(`SELECT status FROM tasks WHERE parent_id = ?`).all(parent.id) as Array<{ status: string }>; + if (!kids.every((k) => k.status === 'done' || k.status === 'cancelled')) break; + this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), parent.id); + this.emit(parent.project_id, parent.id, 'status.changed', { from: 'decomposed', to: 'done', auto: 'children-done' }); + this.releaseDependents(projectId); // 容器本身可能是别人的依赖 + cur = parent; + } + } + /** * 全量依赖对账(幂等):ready 但依赖未满足 → blocked;blocked 但依赖已满足 → ready。 * daemon 启动时调用,纠正存量数据。 @@ -361,6 +388,22 @@ export class Store { return { blocked: nBlocked, released: nReleased }; } + /** + * daemon 启动时的中断现场恢复:上次进程退出时仍 started 的 run 标 failed, + * 卡在 executing 的任务转 failed→queued 等编排器重新领取。 + * 注:中断产生的 failed run 会计入该任务的失败次数(多次中断+真失败可能提前转 needs_attention,可接受)。 + */ + reconcileInterrupted(): { runs: number; tasks: number } { + const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started'`).all() as RunRow[]; + for (const r of runs) this.finishRun(r.id, 'failed', { error: 'daemon 重启,执行中断' }); + const rows = this.db.prepare(`SELECT * FROM tasks WHERE status = 'executing'`).all() as TaskRow[]; + for (const t of rows) { + this.transition(t.id, 'failed', { auto: 'daemon-restart' }); + this.transition(t.id, 'queued', { auto: 'daemon-restart' }); + } + return { runs: runs.length, tasks: rows.length }; + } + /** * 仅供导入器:旧系统中已完成的任务直接置 done(绕过执行链路与依赖落位—— * 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。 @@ -372,7 +415,7 @@ export class Store { if (from === 'done') return this.getTask(taskId)!; this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), taskId); this.emit(row.project_id, taskId, 'status.changed', { from, to: 'done', auto: 'import-done', actor }); - this.releaseDependents(row.project_id); + this.afterDone(row.project_id, taskId); return this.getTask(taskId)!; } @@ -392,7 +435,7 @@ export class Store { this.emit(row.project_id, taskId, 'status.changed', { from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta, }); - if (actual === 'done') this.releaseDependents(row.project_id); + if (actual === 'done') this.afterDone(row.project_id, taskId); return this.getTask(taskId)!; } @@ -444,7 +487,7 @@ export class Store { }); txn(); this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null }); - if (to === 'done') this.releaseDependents(row.project_id); // 完成 → 自动放行依赖它的任务 + if (to === 'done') this.afterDone(row.project_id, taskId); // 完成 → 放行依赖 + 容器自动收口 return this.getTask(taskId)!; } @@ -494,6 +537,14 @@ export class Store { } // ---------- Events ---------- + /** 单任务全量事件(升序):状态流转/审批/运行历史,供归档详情时间线 */ + listTaskEvents(taskId: string): Event[] { + const rows = this.db.prepare( + `SELECT * FROM events WHERE task_id = ? ORDER BY at`, + ).all(taskId) as EventRow[]; + return rows.map(rowToEvent); + } + listEvents(projectId: string, limit = 200): Event[] { const rows = this.db.prepare( `SELECT * FROM events WHERE project_id = ? ORDER BY at DESC LIMIT ?`, @@ -506,16 +557,14 @@ export class Store { * 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。 */ nextExecutable(projectId: string): Task | null { - const rows = this.db.prepare( - `SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority ASC, created_at`, - ).all(projectId) as TaskRow[]; - for (const r of rows) { - if (this.childrenOf(r.id).length > 0) continue; // 非叶子跳过 - const deps = JSON.parse(r.deps) as string[]; - const depsDone = deps.every((d) => this.getTaskRow(d)?.status === 'done'); - if (!depsDone) continue; - return rowToTask(r, this.listApprovals(r.id)); - } - return null; + const all = this.listTasks(projectId); + const byId = new Map(all.map((t) => [t.id, t])); + const parents = new Set(all.filter((t) => t.parentId).map((t) => t.parentId as string)); + const candidates = all.filter((t) => + t.status === 'ready' && !parents.has(t.id) && + t.deps.every((d) => byId.get(d)?.status === 'done'), + ); + const ranked = rankByScore(candidates, all); // 与编排器同一打分(见 model/scoring.ts) + return ranked[0]?.task ?? null; } } diff --git a/test/models.test.ts b/test/models.test.ts new file mode 100644 index 0000000..3684d75 --- /dev/null +++ b/test/models.test.ts @@ -0,0 +1,98 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { pickModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN } from '../src/executor/models.js'; +import type { Project, Task } from '../src/model/types.js'; +import type { Complexity } from '../src/model/complexity.js'; + +function fakeTask(complexity: Complexity): Task { + return { complexity } as Task; +} +function fakeProject(model: string | null = null): Project { + return { model } as Project; +} + +/** 临时设置 env,跑完恢复(避免污染其他用例) */ +function withEnv(vars: Record, fn: () => void): void { + const saved = new Map(); + for (const [k, v] of Object.entries(vars)) { + saved.set(k, process.env[k]); + process.env[k] = v; + } + try { + fn(); + } finally { + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +const ENV_KEYS = [ + 'MAESTRO_MODEL_EASY', 'MAESTRO_MODEL_MEDIUM', 'MAESTRO_MODEL_HARD', + 'MAESTRO_MODEL_REVIEW_EASY', 'MAESTRO_MODEL_REVIEW_MEDIUM', 'MAESTRO_MODEL_REVIEW_HARD', +]; + +test('pickModel:复杂度映射默认值(executor / reviewer)', () => { + // 防止外部 env 干扰:先清空再断言默认 + withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => { + const p = fakeProject(); + assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6'); + assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8'); + assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); + assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); + assert.equal(pickModel(fakeTask('medium'), p, 'reviewer'), 'claude-opus-4-8'); + assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-opus-4-8'); + }); +}); + +test('pickModel:project.model 最优先(两个角色、所有复杂度都用它)', () => { + withEnv({ MAESTRO_MODEL_HARD: 'claude-haiku-4-5' }, () => { + const p = fakeProject('claude-opus-4-6'); + for (const c of ['easy', 'medium', 'hard'] as const) { + assert.equal(pickModel(fakeTask(c), p, 'executor'), 'claude-opus-4-6'); + assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-opus-4-6'); + } + }); +}); + +test('pickModel:env 覆盖默认值,且 executor/reviewer 各用各的 env', () => { + withEnv({ + MAESTRO_MODEL_EASY: 'claude-haiku-4-5', + MAESTRO_MODEL_REVIEW_HARD: 'claude-fable-5', + }, () => { + const p = fakeProject(); + assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-haiku-4-5'); + assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); // reviewer 不受 executor env 影响 + assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5'); + assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); // executor hard 默认即 fable + }); +}); + +test('pickModel:env 为空白字符串视为未设置', () => { + withEnv({ MAESTRO_MODEL_MEDIUM: ' ' }, () => { + assert.equal(pickModel(fakeTask('medium'), fakeProject(), 'executor'), 'claude-opus-4-8'); + }); +}); + +test('isModelError:模型不可用类错误才触发回退', () => { + // 应触发 + assert.ok(isModelError('model claude-fable-5 not_found')); + assert.ok(isModelError('Invalid model: claude-x')); + assert.ok(isModelError('permission denied for model claude-fable-5')); + assert.ok(isModelError('404 model does not exist')); + assert.ok(isModelError('The requested model is unavailable')); + assert.ok(isModelError('unknown model "claude-fable-5"')); + // 不应触发 + assert.ok(!isModelError('执行超时')); + assert.ok(!isModelError('network connection refused')); + assert.ok(!isModelError('not found: /tmp/some/file')); // 没提到 model + assert.ok(!isModelError('model produced empty output')); // 提到 model 但不是可用性错误 +}); + +test('pickFallbackModel:回退链取第一个 ≠ 失败模型的', () => { + assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6']); + assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8'); + assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6'); + assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-opus-4-8'); +}); diff --git a/test/orchestrator.test.ts b/test/orchestrator.test.ts new file mode 100644 index 0000000..08c9da6 --- /dev/null +++ b/test/orchestrator.test.ts @@ -0,0 +1,284 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Store } from '../src/store/index.js'; +import { createOrchestrator, MAX_RETRIES, type OrchestratorDeps } from '../src/daemon/orchestrator.js'; +import type { RunnerResult } from '../src/executor/runner.js'; +import type { ReviewResult } from '../src/executor/reviewer.js'; +import type { Autonomy } from '../src/model/types.js'; + +const noopLog = { info: (): void => undefined, error: (): void => undefined }; + +function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +const okRun: RunnerResult = { ok: true, transcriptRef: '/tmp/fake.jsonl', sessionId: 'sess-mock-1', finalText: '执行自述:改了 README' }; + +const okReview: ReviewResult = { + summary: '## 做了什么\nmock 复审通过', + verdict: 'approve', + transcriptRef: '/tmp/fake-review.jsonl', + sessionId: 'sess-review-1', +}; + +/** 全 mock 依赖(不真起 CC、不动 git):可按用例覆盖 */ +function mockDeps(overrides: Partial = {}): OrchestratorDeps { + return { + createWorktree: async (_repo, taskId) => ({ dir: `/tmp/fake-wt/${taskId}`, branch: `maestro/${taskId}` }), + worktreeDiff: async () => ({ diffSummary: ' README.md | 1 +', commits: ['abc1234 hello maestro'] }), + verify: async () => ({ ok: true, exitCode: 0, logRef: null }), + runner: async () => okRun, + reviewer: async () => okReview, + ...overrides, + }; +} + +function setup(autonomy: Autonomy, concurrency = 1): { store: Store; projectId: string } { + const store = new Store(':memory:'); + const p = store.createProject({ + name: 'orch', repoPath: '/tmp/orch-repo-' + Math.random(), autonomy, concurrency, + }); + return { store, projectId: p.id }; +} + +async function settle(): Promise { + await new Promise((r) => setImmediate(r)); +} + +test('autonomy=manual:编排器不领取任何任务', async () => { + const { store, projectId } = setup('manual'); + const t = store.createTask({ projectId, title: 'easy task', complexity: 'easy' }); + let calls = 0; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async () => { calls++; return okRun; }, + })); + orch.tick(); + await orch.drain(); + assert.equal(calls, 0); + assert.equal(store.getTask(t.id)!.status, 'ready'); + store.close(); +}); + +test('autonomy=auto-easy:只领 easy,medium ready 不动', async () => { + const { store, projectId } = setup('auto-easy', 5); + const easy = store.createTask({ projectId, title: 'small', complexity: 'easy' }); + const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' }); + store.setSpec(medium.id, '方案'); + store.transition(medium.id, 'spec_review'); + store.decide(medium.id, 'accept', 'user'); // medium → ready + assert.equal(store.getTask(medium.id)!.status, 'ready'); + + const ran: string[] = []; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async (task) => { ran.push(task.id); return okRun; }, + })); + orch.tick(); + await orch.drain(); + + assert.deepEqual(ran, [easy.id]); + assert.equal(store.getTask(easy.id)!.status, 'exec_review'); + assert.equal(store.getTask(medium.id)!.status, 'ready'); // 不自动跑 + store.close(); +}); + +test('autonomy=auto-approved:领全部 ready(含 medium),依赖未满足/非叶子不领', async () => { + const { store, projectId } = setup('auto-approved', 5); + const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' }); + store.setSpec(medium.id, '方案'); + store.transition(medium.id, 'spec_review'); + store.decide(medium.id, 'accept', 'user'); + // 依赖未 done 的任务:建在 medium 上 → blocked,不可领 + const dep = store.createTask({ projectId, title: 'after-mid', complexity: 'easy', deps: [medium.id] }); + assert.equal(store.getTask(dep.id)!.status, 'blocked'); + + const ran: string[] = []; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async (task) => { ran.push(task.id); return okRun; }, + })); + orch.tick(); + await orch.drain(); + + assert.deepEqual(ran, [medium.id]); + assert.equal(store.getTask(medium.id)!.status, 'exec_review'); + assert.equal(store.getTask(dep.id)!.status, 'blocked'); + store.close(); +}); + +test('concurrency=1:同项目同轮只领 1 个,跑完下一轮再领', async () => { + const { store, projectId } = setup('auto-approved', 1); + const t1 = store.createTask({ projectId, title: 'a', complexity: 'easy' }); + const t2 = store.createTask({ projectId, title: 'b', complexity: 'easy' }); + + const gate = deferred(); + const started: string[] = []; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async (task) => { started.push(task.id); await gate.promise; return okRun; }, + })); + + orch.tick(); + await settle(); + assert.deepEqual(started, [t1.id], '并发=1 只应启动第一个任务'); + assert.equal(store.getTask(t1.id)!.status, 'executing'); + assert.equal(store.getTask(t2.id)!.status, 'ready'); + assert.equal(orch.inflight.size, 1); + + orch.tick(); // 在途占满 → 本轮不领 + await settle(); + assert.deepEqual(started, [t1.id]); + + gate.resolve(); + await orch.drain(); + assert.equal(store.getTask(t1.id)!.status, 'exec_review'); + + orch.tick(); // 槽位释放 → 领第二个 + gate.resolve(); + await orch.drain(); + assert.deepEqual(started, [t1.id, t2.id]); + assert.equal(store.getTask(t2.id)!.status, 'exec_review'); + store.close(); +}); + +test('成功路径:状态流转 + setResult(含复审 summary/verdict) + 双 run succeeded', async () => { + const { store, projectId } = setup('auto-easy'); + const t = store.createTask({ projectId, title: 'tweak', complexity: 'easy' }); + store.setOperations(t.id, '在 README.md 追加一行'); + + const seen: string[] = []; + store.subscribe((e) => { if (e.type === 'status.changed' && e.taskId === t.id) seen.push(String(e.payload.to)); }); + + let reportSeen: string | null = null; + const orch = createOrchestrator(store, noopLog, mockDeps({ + reviewer: async (_task, _project, _wt, _runId, executorReport) => { + reportSeen = executorReport; + return okReview; + }, + })); + orch.tick(); + await orch.drain(); + + const done = store.getTask(t.id)!; + assert.equal(done.status, 'exec_review'); + assert.deepEqual(seen, ['queued', 'executing', 'exec_review']); + assert.deepEqual(done.result, { + branch: `maestro/${t.id}`, + worktree: `/tmp/fake-wt/${t.id}`, + diffSummary: ' README.md | 1 +', + commits: ['abc1234 hello maestro'], + prUrl: null, + summary: '## 做了什么\nmock 复审通过', + verdict: 'approve', + }); + assert.equal(reportSeen, '执行自述:改了 README'); // runner finalText 传给 reviewer 作执行者自述 + + const runs = store.listRuns(t.id); + assert.equal(runs.length, 2); + const executor = runs.find((r) => r.kind === 'executor')!; + assert.equal(executor.status, 'succeeded'); + assert.equal(executor.branch, `maestro/${t.id}`); + assert.equal(executor.transcriptRef, '/tmp/fake.jsonl'); + assert.equal(executor.claudeSessionId, 'sess-mock-1'); + const reviewer = runs.find((r) => r.kind === 'reviewer')!; + assert.equal(reviewer.status, 'succeeded'); + assert.equal(reviewer.transcriptRef, '/tmp/fake-review.jsonl'); + assert.equal(reviewer.claudeSessionId, 'sess-review-1'); + store.close(); +}); + +test('reviewer verdict=reject 也照常落进 result(最终裁决仍归用户)', async () => { + const { store, projectId } = setup('auto-easy'); + const t = store.createTask({ projectId, title: 'risky', complexity: 'easy' }); + const orch = createOrchestrator(store, noopLog, mockDeps({ + reviewer: async () => ({ ...okReview, summary: '发现问题', verdict: 'reject' as const }), + })); + orch.tick(); + await orch.drain(); + const done = store.getTask(t.id)!; + assert.equal(done.status, 'exec_review'); + assert.equal(done.result!.verdict, 'reject'); + assert.equal(done.result!.summary, '发现问题'); + store.close(); +}); + +test('复审失败不挡任务:照常进 exec_review,summary 记失败原因、verdict=null', async () => { + const { store, projectId } = setup('auto-easy'); + const t = store.createTask({ projectId, title: 'review-broken', complexity: 'easy' }); + const orch = createOrchestrator(store, noopLog, mockDeps({ + reviewer: async () => { throw new Error('复审 CC 崩了'); }, + })); + orch.tick(); + await orch.drain(); + + const done = store.getTask(t.id)!; + assert.equal(done.status, 'exec_review'); // 不挡结果闸 + assert.equal(done.result!.verdict, null); + assert.equal(done.result!.summary, '自动复审失败:复审 CC 崩了'); + + const runs = store.listRuns(t.id); + assert.equal(runs.find((r) => r.kind === 'executor')!.status, 'succeeded'); + const reviewer = runs.find((r) => r.kind === 'reviewer')!; + assert.equal(reviewer.status, 'failed'); + assert.match(reviewer.error ?? '', /复审 CC 崩了/); + store.close(); +}); + +test('verify 不过:按失败处理(run failed + 重新入队)', async () => { + const { store, projectId } = setup('auto-easy'); + const t = store.createTask({ projectId, title: 'v', complexity: 'easy' }); + const orch = createOrchestrator(store, noopLog, mockDeps({ + verify: async () => ({ ok: false, exitCode: 1, logRef: '/tmp/v.log', error: 'verify 失败(exit 1)' }), + })); + orch.tick(); + await orch.drain(); + assert.equal(store.getTask(t.id)!.status, 'queued'); // 第一次失败 → 重新入队 + const runs = store.listRuns(t.id); + assert.equal(runs.length, 1); + assert.equal(runs[0].status, 'failed'); + assert.match(runs[0].error ?? '', /verify 失败/); + store.close(); +}); + +test(`失败重试:重试 ${MAX_RETRIES} 次后 → needs_attention(共 ${MAX_RETRIES + 1} 次失败 run)`, async () => { + const { store, projectId } = setup('auto-easy'); + const t = store.createTask({ projectId, title: 'flaky', complexity: 'easy' }); + + let attempts = 0; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async () => { attempts++; return { ok: false, transcriptRef: null, sessionId: null, error: `boom #${attempts}` }; }, + })); + + for (let i = 1; i <= MAX_RETRIES; i++) { + orch.tick(); + await orch.drain(); + assert.equal(store.getTask(t.id)!.status, 'queued', `第 ${i} 次失败后应重新入队`); + } + + orch.tick(); // 最后一次重试也失败 + await orch.drain(); + assert.equal(store.getTask(t.id)!.status, 'needs_attention'); + assert.equal(attempts, MAX_RETRIES + 1); + + const failed = store.listRuns(t.id).filter((r) => r.status === 'failed'); + assert.equal(failed.length, MAX_RETRIES + 1); + + orch.tick(); // needs_attention 不会再被领取 + await orch.drain(); + assert.equal(attempts, MAX_RETRIES + 1); + store.close(); +}); + +test('project paused:不领取', async () => { + const { store, projectId } = setup('auto-approved'); + const t = store.createTask({ projectId, title: 'x', complexity: 'easy' }); + store.patchProject(projectId, { status: 'paused' }); + let calls = 0; + const orch = createOrchestrator(store, noopLog, mockDeps({ + runner: async () => { calls++; return okRun; }, + })); + orch.tick(); + await orch.drain(); + assert.equal(calls, 0); + assert.equal(store.getTask(t.id)!.status, 'ready'); + store.close(); +}); diff --git a/test/reviewer.test.ts b/test/reviewer.test.ts new file mode 100644 index 0000000..2651dc1 --- /dev/null +++ b/test/reviewer.test.ts @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseVerdict, buildReviewPrompt } from '../src/executor/reviewer.js'; +import type { Project, Task } from '../src/model/types.js'; + +test('parseVerdict:approve', () => { + const { summary, verdict } = parseVerdict('## 做了什么\n改了 README\n\n## 结论\n建议通过\nVERDICT: approve'); + assert.equal(verdict, 'approve'); + assert.equal(summary, '## 做了什么\n改了 README\n\n## 结论\n建议通过'); // VERDICT 行已剔除 +}); + +test('parseVerdict:reject(大小写/前后空白宽容)', () => { + const { summary, verdict } = parseVerdict('问题很多\n verdict: REJECT \n'); + assert.equal(verdict, 'reject'); + assert.equal(summary, '问题很多'); +}); + +test('parseVerdict:解析不到 = null,summary 原样保留', () => { + const { summary, verdict } = parseVerdict('## 结论\n建议通过,但忘了写 VERDICT 行'); + assert.equal(verdict, null); + assert.equal(summary, '## 结论\n建议通过,但忘了写 VERDICT 行'); +}); + +test('parseVerdict:多个 VERDICT 行取最后一个,全部从 summary 剔除', () => { + const { summary, verdict } = parseVerdict('VERDICT: approve\n复查后改判\nVERDICT: reject'); + assert.equal(verdict, 'reject'); + assert.equal(summary, '复查后改判'); +}); + +test('parseVerdict:VERDICT 行带多余内容不算(如模板原文 approve|reject)', () => { + const { verdict } = parseVerdict('VERDICT: approve|reject'); + assert.equal(verdict, null); +}); + +test('buildReviewPrompt:含任务说明、执行者自述、diff 指令与固定模板', () => { + const task = { id: 'task_1', title: '修复登录', operations: '改 auth.ts', spec: null, plan: null } as Task; + const project = { defaultBranch: 'main' } as Project; + const wt = { dir: '/tmp/wt', branch: 'maestro/task_1' }; + const prompt = buildReviewPrompt(task, project, wt, '我改了 auth.ts 并加了测试'); + + assert.match(prompt, /修复登录/); + assert.match(prompt, /改 auth\.ts/); + assert.match(prompt, /我改了 auth\.ts 并加了测试/); + assert.match(prompt, /git diff main\.\.\.maestro\/task_1/); + for (const section of ['## 做了什么', '## 怎么做的', '## 测试情况', '## Code Review', '## 安全 Review', '## 结论']) { + assert.ok(prompt.includes(section), `模板缺少 ${section}`); + } + assert.match(prompt, /VERDICT: approve\|reject/); + assert.match(prompt, /只读权限/); +}); + +test('buildReviewPrompt:执行者无自述时给占位说明', () => { + const task = { id: 'task_2', title: 't', operations: null, spec: null, plan: null } as Task; + const project = { defaultBranch: 'main' } as Project; + const prompt = buildReviewPrompt(task, project, { dir: '/x', branch: 'maestro/task_2' }, ''); + assert.match(prompt, /执行者未留下自述/); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 701fa7f..ee9135c 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -96,7 +96,7 @@ test('exec_review 结果闸:accept → done', () => { s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); - s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null }); + s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null, summary: null, verdict: null }); s.transition(t.id, 'exec_review'); const done = s.decide(t.id, 'accept', 'user'); assert.equal(done.status, 'done'); @@ -164,3 +164,47 @@ test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked( assert.equal(r2.blocked + r2.released, 0); s.close(); }); + +test('score 调度:解锁加权 > 链条惯性 > 自身分;nextExecutable 取最高分', () => { + const s = freshStore(); + const p = s.createProject({ name: 'sc', repoPath: '/tmp/sc-' + Math.random() }); + // A:P1 无依赖,但有两条 P0 blocked 任务等它 → score = 2 + 3 + 3 = 8 + const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy', priority: 1 }); + s.createTask({ projectId: p.id, title: 'W1', complexity: 'easy', priority: 0, deps: [a.id] }); + s.createTask({ projectId: p.id, title: 'W2', complexity: 'easy', priority: 0, deps: [a.id] }); + // B:P0 无依赖无人等 → score = 3 + s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', priority: 0 }); + const next = s.nextExecutable(p.id); + assert.equal(next?.title, 'A'); // 解锁两条 P0 的 A(8) 压过孤立 P0 的 B(3) + s.close(); +}); + +test('createTask:deps 引用不存在/跨项目任务被拒绝', () => { + const s = freshStore(); + const p1 = s.createProject({ name: 'd1', repoPath: '/tmp/d1-' + Math.random() }); + const p2 = s.createProject({ name: 'd2', repoPath: '/tmp/d2-' + Math.random() }); + const other = s.createTask({ projectId: p2.id, title: 'x', complexity: 'easy' }); + assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad', complexity: 'easy', deps: ['tsk_nope'] }), StoreError); + assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad2', complexity: 'easy', deps: [other.id] }), StoreError); + s.close(); +}); + +test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => { + const s = freshStore(); + const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() }); + const root = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' }); + s.setPlan(root.id, '拆 2 子'); + s.transition(root.id, 'plan_review'); + s.decide(root.id, 'accept', 'user'); // decomposed + const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: '子1', complexity: 'easy' }); + const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: '子2', complexity: 'easy' }); + const finish = (tid) => { + s.transition(tid, 'queued'); s.transition(tid, 'executing'); + s.transition(tid, 'exec_review'); s.decide(tid, 'accept', 'user'); + }; + finish(c1.id); + assert.equal(s.getTask(root.id).status, 'decomposed'); // 还有子没完,容器不动 + finish(c2.id); + assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done + s.close(); +}); diff --git a/test/worktree.test.ts b/test/worktree.test.ts new file mode 100644 index 0000000..fa4693c --- /dev/null +++ b/test/worktree.test.ts @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createWorktree, worktreeDiff, removeWorktree, worktreeDirFor, branchFor } from '../src/executor/worktree.js'; + +function gitSync(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +/** /tmp 下建一个带 1 个 commit 的真实 git repo(main 分支) */ +function makeRepo(): string { + const repo = mkdtempSync(join(tmpdir(), 'maestro-wt-repo-')); + gitSync(repo, ['init', '-b', 'main']); + gitSync(repo, ['config', 'user.name', 'maestro-test']); + gitSync(repo, ['config', 'user.email', 'test@maestro.local']); + writeFileSync(join(repo, 'README.md'), '# demo\n'); + gitSync(repo, ['add', '-A']); + gitSync(repo, ['commit', '-m', 'init']); + return repo; +} + +test('worktree:create / diff / remove 在真实 git repo 上工作', async (t) => { + // worktree 根目录隔离到临时 MAESTRO_DATA_DIR,不碰 ~/.maestro + const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data-')); + const prevDataDir = process.env.MAESTRO_DATA_DIR; + process.env.MAESTRO_DATA_DIR = dataDir; + const repo = makeRepo(); + t.after(() => { + if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR; + else process.env.MAESTRO_DATA_DIR = prevDataDir; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + }); + + const taskId = 'tsk_test123'; + + // 1) 创建:目录与分支符合约定 + const wt = await createWorktree(repo, taskId, 'main'); + assert.equal(wt.branch, branchFor(taskId)); + assert.equal(wt.dir, worktreeDirFor(repo, taskId)); + assert.ok(existsSync(join(wt.dir, 'README.md')), 'worktree 应检出 README.md'); + const head = gitSync(wt.dir, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + assert.equal(head, wt.branch); + + // 2) 空分支 diff:无 commit、无摘要 + const empty = await worktreeDiff(repo, wt.dir, wt.branch, 'main'); + assert.equal(empty.commits.length, 0); + assert.equal(empty.diffSummary, ''); + + // 3) 在 worktree 提交一笔改动 → diff 能看到 commit 与 --stat 摘要 + writeFileSync(join(wt.dir, 'README.md'), '# demo\nhello maestro\n'); + gitSync(wt.dir, ['add', '-A']); + gitSync(wt.dir, ['commit', '-m', `maestro(${taskId}): tweak readme`]); + const diff = await worktreeDiff(repo, wt.dir, wt.branch, 'main'); + assert.equal(diff.commits.length, 1); + assert.ok(diff.commits[0].includes(taskId), `commit 行应含任务 id:${diff.commits[0]}`); + assert.ok(diff.diffSummary.includes('README.md'), `diffSummary 应含文件名:${diff.diffSummary}`); + + // 4) 已存在同名 worktree/分支时重建(重试场景):从 main 干净重来 + const wt2 = await createWorktree(repo, taskId, 'main'); + assert.equal(wt2.dir, wt.dir); + const diff2 = await worktreeDiff(repo, wt2.dir, wt2.branch, 'main'); + assert.equal(diff2.commits.length, 0, '重建后分支应回到 baseBranch,无旧 commit'); + + // 5) remove:目录与注册项移除,主 repo 不受影响 + await removeWorktree(repo, wt2.dir); + assert.ok(!existsSync(wt2.dir), 'worktree 目录应被移除'); + const list = gitSync(repo, ['worktree', 'list']); + assert.ok(!list.includes(taskId), 'git worktree list 不应再含该任务'); + assert.ok(existsSync(join(repo, 'README.md')), '主 repo 不受影响'); +}); + +test('worktree:错误透传(不存在的 baseBranch)', async (t) => { + const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data2-')); + const prevDataDir = process.env.MAESTRO_DATA_DIR; + process.env.MAESTRO_DATA_DIR = dataDir; + const repo = makeRepo(); + t.after(() => { + if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR; + else process.env.MAESTRO_DATA_DIR = prevDataDir; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + }); + + await assert.rejects( + () => createWorktree(repo, 'tsk_badbase', 'no-such-branch'), + /git worktree add/, + ); +}); diff --git a/web/app.js b/web/app.js index 190840c..8b98db8 100644 --- a/web/app.js +++ b/web/app.js @@ -7,7 +7,7 @@ const STATUS_LABEL = { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', - ready: '可执行', blocked: '被依赖阻塞', queued: '排队中', + ready: '可执行', blocked: '被阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消', }; @@ -38,12 +38,14 @@ const AUTONOMY_LABEL = { // 任务树筛选:状态按状态机分组(组可整组开关,组内可单选) const FILTER_GROUPS = [ ['待办', ['init', 'ready', 'blocked']], - ['进行中', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']], + ['进行中', ['analyzing', 'speccing', 'queued', 'executing']], // 已拆解是容器态,不算进行中 + ['容器', ['decomposed']], ['待审批', ['plan_review', 'spec_review', 'exec_review']], ['异常', ['failed', 'needs_attention']], - ['挂起', ['paused', 'cancelled']], - ['完成', ['done']], + ['挂起', ['paused']], ]; +// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页) +const ARCHIVED = new Set(['done', 'cancelled']); // ── 全局状态 ── const S = { @@ -55,13 +57,14 @@ const S = { collapsed: new Set(), expanded: new Set(), rejectOpen: new Set(), - filter: { cplx: new Set(), status: new Set(), kw: '' },// 任务树筛选(内存态) + filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离) matchCount: 0, agents: null, // GET /api/agents 结果(404 时为 null) cplxMenuFor: null, // 复杂度下拉打开的任务 id syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast) previewId: null, // 全局预览中的任务 id previewReject: false, // 预览层内驳回意见框是否展开 + archive: { page: 1, size: 20 }, // 归档区分页 }; const $ = (sel) => document.querySelector(sel); @@ -87,6 +90,11 @@ function fmtRel(iso) { return `${Math.floor(h / 24)} 天前`; } +/** 模型名缩写显示:claude-fable-5 → fable-5 */ +function shortModel(m) { + return String(m || '—').replace(/^claude-/, ''); +} + // ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ── function mdToHtml(src) { const inline = (s) => s @@ -251,6 +259,7 @@ function renderAll() { renderFilterBar(); renderEvents(); renderParentOptions(); + renderArchive(); renderPreview(); restoreDrafts(snap); } @@ -275,15 +284,20 @@ function renderTopbar() { $('#projMeta').textContent = p ? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}` : ''; - // 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中 + // 徽章组(全部不含已完成:done / 已拆解容器 / 取消): + // 待审批 / 待执行(ready 叶子依赖满足 + 排队中) / 执行中 / 被阻塞 / 总量 $('#badgeGroup').hidden = !p; + const FINISHED = new Set(['done', 'decomposed', 'cancelled']); + const alive = S.tasks.filter((x) => !FINISHED.has(x.status)); const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId)); const byId = new Map(S.tasks.map((x) => [x.id, x])); - const nReady = S.tasks.filter((x) => - x.status === 'ready' && !hasKids.has(x.id) && - (x.deps || []).every((d) => byId.get(d)?.status === 'done'), + const nReady = alive.filter((x) => + (x.status === 'ready' && !hasKids.has(x.id) && + (x.deps || []).every((d) => byId.get(d)?.status === 'done')) || + x.status === 'queued', ).length; - const nRun = S.tasks.filter((x) => x.status === 'executing').length; + const nRun = alive.filter((x) => x.status === 'executing').length; + const nBlocked = alive.filter((x) => x.status === 'blocked').length; const setBdg = (sel, n) => { const el = $(sel); el.querySelector('.bdg-n').textContent = n; @@ -292,6 +306,8 @@ function renderTopbar() { setBdg('#bdgGate', S.approvals.length); setBdg('#bdgReady', nReady); setBdg('#bdgRun', nRun); + setBdg('#bdgBlocked', nBlocked); + setBdg('#bdgTotal', alive.length); // 同步 todo / 项目配置入口 $('#topActions').hidden = !p; @@ -329,6 +345,8 @@ function renderAgents() {
${esc(g.projectName)} 并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')} + 调度 score 优先 + ${g.models ? `模型 E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}` : ''}
${g.active.map((r) => `
@@ -354,6 +372,7 @@ function gateDocs(t) { if (gate === 'exec') { if (t.result) { const r = t.result; + blocks.push(reviewSummaryBlock(r)); // 复审 SUMMARY + verdict 徽章,置于 DIFF 摘要上方 blocks.push(`
RESULT · 执行结果
${r.branch ? `
分支
${esc(r.branch)}
` : ''} @@ -402,6 +421,171 @@ function renderGates() { }).join(''); } +// ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ── +function renderArchive() { + const sec = $('#archiveSection'); + if (!S.currentProjectId) { sec.hidden = true; return; } + // 只列深度 1 的归档树根,行内标注子任务数 + const m = childrenMap(S.tasks); + const arch = archivedIdSet(); + const subCount = (t) => { + let n = 0; + const stack = [...(m.get(t.id) || [])]; + while (stack.length) { const x = stack.pop(); n++; for (const c of m.get(x.id) || []) stack.push(c); } + return n; + }; + const items = (m.get('__root__') || []) + .filter((t) => arch.has(t.id)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + sec.hidden = items.length === 0; + if (sec.hidden) return; + + const a = S.archive; + const pages = Math.max(1, Math.ceil(items.length / a.size)); + if (a.page > pages) a.page = pages; + const slice = items.slice((a.page - 1) * a.size, a.page * a.size); + + $('#archiveCount').textContent = items.length; + $('#archiveList').innerHTML = slice.map((t) => { + const n = subCount(t); + return ` +
+ ${esc(t.title)}${esc(t.id.slice(-6))} + ${n ? `含 ${n} 子任务` : ''} + + ${cplxBadge(t.complexity)} + ${statusChip(t.status)} + ${fmtRel(t.updatedAt)} +
`; + }).join(''); + // 底部:翻页 + 每页条数 chip 组(替代原生下拉) + const sizeChips = [10, 20, 50, 100].map((n) => + ``, + ).join(''); + $('#archivePager').innerHTML = ` + ${pages > 1 ? ` + + 第 ${a.page} / ${pages} 页 + ` : ''} + 每页${sizeChips}`; +} + +// ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ── +function fmtFull(iso) { + try { return new Date(iso).toLocaleString('zh-CN', { hour12: false }); } catch { return iso || '—'; } +} +function fmtDuration(startIso, endIso) { + if (!startIso || !endIso) return '—'; + const ms = new Date(endIso) - new Date(startIso); + if (!Number.isFinite(ms) || ms < 0) return '—'; + const s = Math.round(ms / 1000); + if (s < 60) return `${s} 秒`; + return `${Math.floor(s / 60)} 分 ${s % 60} 秒`; +} +/** 事件 → 时间线条目(人或事:by/auto/actor/原因) */ +function tlActor(p) { + const who = []; + if (p.by) who.push(p.by === 'orchestrator' ? '编排器' : p.by); + if (p.actor) who.push(p.actor === 'importer' ? '导入器' : p.actor); + if (p.auto) who.push({ 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位', + 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复' }[p.auto] || p.auto); + return who.join(' · '); +} + +async function openArchiveDetail(id) { + try { + const [task, runs, events] = await Promise.all([ + api(`/api/tasks/${id}`), api(`/api/tasks/${id}/runs`), api(`/api/tasks/${id}/events`), + ]); + renderArchiveModal(task, runs, events); + } catch (e) { toast(e.message); } +} + +function renderArchiveModal(t, runs, events) { + const root = $('#archiveModalRoot'); + const byId = new Map(S.tasks.map((x) => [x.id, x])); + const parent = t.parentId ? byId.get(t.parentId) : null; + const r = t.result; + const RUN_KIND = { executor: '执行', reviewer: '复审', planner: '规划' }; + const RUN_ST = { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' }; + + const kv = (dt, dd) => `
${dt}
${dd}
`; + const attrs = `
+ ${kv('id', esc(t.id))} + ${kv('复杂度', `${CPLX_LABEL[t.complexity] || esc(t.complexity)}`)} + ${kv('优先级', `P${esc(String(t.priority))}`)} + ${kv('状态', STATUS_LABEL[t.status] || esc(t.status))} + ${kv('深度', esc(String(t.depth)))} + ${parent ? kv('父任务', esc(parent.title)) : ''} + ${t.deps && t.deps.length ? kv('依赖', t.deps.map((d) => esc(byId.get(d)?.title || d)).join('、')) : ''} + ${kv('创建时间', fmtFull(t.createdAt))} + ${kv('最后更新', fmtFull(t.updatedAt))} +
`; + + const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']] + .filter(([f]) => t[f]) + .map(([f, label]) => `
${label}
${mdToHtml(t[f])}
`) + .join(''); + + const runRows = runs.length ? `
执行历史(${runs.length} 次 run)
+
${runs.map((x) => kv( + `${RUN_KIND[x.kind] || esc(x.kind)} · ${RUN_ST[x.status] || esc(x.status)}`, + `${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · 时长 ${fmtDuration(x.startedAt, x.endedAt)}` + + (x.error ? `
错误:${esc(x.error)}` : '') + + (x.transcriptRef ? `
转录 ${esc(x.transcriptRef)}` : ''), + )).join('')}
` : ''; + + const resultBlock = r ? `
执行结果
+ ${r.summary ? `
${mdToHtml(r.summary)}
` : ''} +
+ ${r.verdict ? kv('复审建议', r.verdict === 'approve' ? '建议通过' : '建议拒绝') : ''} + ${r.branch ? kv('分支', esc(r.branch)) : ''} + ${r.worktree ? kv('worktree', esc(r.worktree)) : ''} + ${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('
')) : ''} +
+ ${r.diffSummary ? `
${esc(r.diffSummary)}
` : ''}` : ''; + + const approvalRows = t.approvals && t.approvals.length ? `
审批记录
+
${t.approvals.map((a) => kv( + `${GATE_LABEL[a.gate] || esc(a.gate)} · ${a.action === 'accept' ? '接受' : '驳回'}`, + `审批人 ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `
意见:${esc(a.reason)}` : ''}`, + )).join('')}
` : ''; + + const timeline = events.length ? `
状态流转时间线
+
${events.map((e) => { + const p = e.payload || {}; + let text = EVENT_LABEL[e.type] || e.type; + if (e.type === 'status.changed') text = `${STATUS_LABEL[p.from] || p.from} → ${STATUS_LABEL[p.to] || p.to}`; + if (e.type === 'approval.granted') text = `审批通过(${GATE_LABEL[p.gate] || p.gate})`; + if (e.type === 'approval.rejected') text = `审批驳回(${GATE_LABEL[p.gate] || p.gate})${p.reason ? ':' + p.reason : ''}`; + if (e.type === 'run.started') text = `run 开始(${RUN_KIND[p.kind] || p.kind || ''})`; + if (e.type === 'run.finished') text = `run 结束(${RUN_ST[p.status] || p.status || ''})`; + const who = tlActor(p); + return `
+ ${fmtFull(e.at)} + ${esc(text)} + ${who ? `${esc(who)}` : ''} +
`; + }).join('')}
` : ''; + + root.hidden = false; + root.innerHTML = ` +
+
+
+ 归档详情 + ${esc(t.title)} + ${cplxBadge(t.complexity)} + ${statusChip(t.status)} + + +
+
+ ${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline} +
+
`; +} + // ── 渲染:全局预览(全屏读方案 + 就地裁决) ── function renderPreview() { const root = $('#previewRoot'); @@ -459,9 +643,24 @@ function statusChip(st) { return `${STATUS_LABEL[st] || esc(st)}`; } -function childrenMap() { +// 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰) +function verdictBadge(v) { + if (v === 'approve') return `建议通过`; + if (v === 'reject') return `建议拒绝`; + return `未复审`; +} + +// 复审 SUMMARY 区块(label + verdict 徽章 + markdown 渲染),无 summary 且未复审时返回空 +function reviewSummaryBlock(r) { + if (!r) return ''; + const label = `
复审 SUMMARY ${verdictBadge(r.verdict)}
`; + if (r.summary) return `${label}
${mdToHtml(r.summary)}
`; + return `${label}
(无复审记录)
`; +} + +function childrenMap(list = S.tasks) { const m = new Map(); - for (const t of S.tasks) { + for (const t of list) { const key = t.parentId || '__root__'; if (!m.has(key)) m.set(key, []); m.get(key).push(t); @@ -469,15 +668,46 @@ function childrenMap() { return m; } +/** + * 归档单位 = 深度 1 任务整树:自身与全部子孙都 done/取消 才算「完成」。 + * 返回归档任务 id 集合(含整树)。子任务先完成但兄弟未完时整树留在任务树里展示进度。 + */ +function archivedIdSet() { + const m = childrenMap(S.tasks); + const subtreeDone = (t) => ARCHIVED.has(t.status) && (m.get(t.id) || []).every(subtreeDone); + const ids = new Set(); + for (const r of m.get('__root__') || []) { + if (!subtreeDone(r)) continue; + const stack = [r]; + while (stack.length) { + const x = stack.pop(); + ids.add(x.id); + for (const c of m.get(x.id) || []) stack.push(c); + } + } + return ids; +} + +/** 任务树里显示的任务(已归档整树沉到底部归档区) */ +function aliveTasks() { + const arch = archivedIdSet(); + return S.tasks.filter((t) => !arch.has(t.id)); +} + // ── 筛选 ── function filterActive() { const f = S.filter; - return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== ''; + return f.cplx.size > 0 || f.status.size > 0 || f.statusGroups.size > 0 || f.kw.trim() !== ''; } function matchTask(t) { const f = S.filter; if (f.cplx.size && !f.cplx.has(t.complexity)) return false; - if (f.status.size && !f.status.has(t.status)) return false; + // 状态条件 = 单选状态 ∪ 已选组的全部成员(任一命中即过) + if (f.status.size || f.statusGroups.size) { + const inStatus = f.status.has(t.status); + const inGroup = [...f.statusGroups].some((gi) => FILTER_GROUPS[gi]?.[1].includes(t.status)); + if (!inStatus && !inGroup) return false; + } const kw = f.kw.trim().toLowerCase(); if (kw && !String(t.title).toLowerCase().includes(kw)) return false; return true; @@ -486,10 +716,11 @@ function matchTask(t) { function computeVisible() { S.matchCount = 0; if (!filterActive()) return null; - const byId = new Map(S.tasks.map((t) => [t.id, t])); + const alive = aliveTasks(); + const byId = new Map(alive.map((t) => [t.id, t])); const visible = new Set(); const matched = new Set(); - for (const t of S.tasks) { + for (const t of alive) { if (!matchTask(t)) continue; matched.add(t.id); let cur = t; @@ -511,11 +742,15 @@ function renderFilterBar() { ``, ).join(''); $('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => { - const sel = sts.filter((s) => f.status.has(s)).length; - const gCls = sel === sts.length ? 'on' : (sel ? 'part' : ''); + // 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身 + const members = sts.map((s) => + ``, + ).join(''); + if (sts.length === 1) return `${members}`; + const gCls = f.statusGroups.has(gi) ? 'on' : ''; return ` - - ${sts.map((s) => ``).join('')} + + ${members} `; }).join(''); const active = filterActive(); @@ -533,7 +768,12 @@ function renderTree() { root.innerHTML = `
EMPTY还没有任务 —— 点上方「+ 新建任务」开始
`; return; } - const m = childrenMap(); + const alive = aliveTasks(); + if (!alive.length) { + root.innerHTML = `
ALL DONE全部任务已完成归档(见页面底部归档区)
`; + return; + } + const m = childrenMap(alive); const vis = computeVisible(); if (vis && !vis.visible.size) { root.innerHTML = `
NO MATCH没有任务命中当前筛选 —— 放宽条件或「清除筛选」
`; @@ -602,7 +842,8 @@ function renderDetail(t) { if (t.result) { const r = t.result; parts.push(` -
RESULT · 执行结果
+
RESULT · 执行结果 ${verdictBadge(r.verdict)}
+ ${r.summary ? `
${mdToHtml(r.summary)}
` : ''}
分支
${esc(r.branch || '—')}
worktree
${esc(r.worktree || '—')}
@@ -811,7 +1052,7 @@ document.addEventListener('click', (ev) => { // 被筛选隐藏时清除筛选,保证可见 const vis = computeVisible(); if (vis && !vis.visible.has(id)) { - S.filter.cplx.clear(); S.filter.status.clear(); S.filter.kw = ''; + S.filter.cplx.clear(); S.filter.status.clear(); S.filter.statusGroups.clear(); S.filter.kw = ''; renderFilterBar(); } // 展开全部祖先 + 展开目标详情 @@ -831,6 +1072,29 @@ document.addEventListener('click', (ev) => { break; } + case 'archive-detail': + openArchiveDetail(id); + break; + case 'archive-modal-close': + $('#archiveModalRoot').hidden = true; + $('#archiveModalRoot').innerHTML = ''; + break; + + case 'archive-size': + S.archive.size = Number(el.dataset.size) || 20; + S.archive.page = 1; + renderArchive(); + break; + + case 'archive-prev': + S.archive.page = Math.max(1, S.archive.page - 1); + renderArchive(); + break; + case 'archive-next': + S.archive.page += 1; + renderArchive(); + break; + case 'cplx-menu': S.cplxMenuFor = S.cplxMenuFor === id ? null : id; renderTree(); @@ -859,17 +1123,16 @@ document.addEventListener('click', (ev) => { break; } case 'filter-group': { - const g = FILTER_GROUPS[Number(el.dataset.gi)]; - if (!g) break; - const sts = g[1]; - const all = sts.every((s) => S.filter.status.has(s)); - sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); }); + const gi = Number(el.dataset.gi); + if (!FILTER_GROUPS[gi]) break; + S.filter.statusGroups.has(gi) ? S.filter.statusGroups.delete(gi) : S.filter.statusGroups.add(gi); renderTree(); renderFilterBar(); break; } case 'filter-clear': S.filter.cplx.clear(); S.filter.status.clear(); + S.filter.statusGroups.clear(); S.filter.kw = ''; $('#filterKw').value = ''; renderTree(); renderFilterBar(); @@ -913,6 +1176,7 @@ document.addEventListener('click', (ev) => { method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }), }); await loadProjects(); // 刷新当前值显示 + $('#configPanel').hidden = true; // 保存成功自动收起 }, '配置已保存'); break; } @@ -977,6 +1241,8 @@ $('#filterKw').addEventListener('input', (ev) => { // Esc 关模态 document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { + const am = $('#archiveModalRoot'); + if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; } if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; } $('#modalRoot').hidden = true; } diff --git a/web/index.html b/web/index.html index 7bc0306..d38ddb0 100644 --- a/web/index.html +++ b/web/index.html @@ -41,19 +41,23 @@
@@ -73,6 +77,10 @@
+
+ + +
@@ -136,6 +144,13 @@
+ + + @@ -170,6 +185,7 @@
+
diff --git a/web/style.css b/web/style.css index 4aa1135..8584a70 100644 --- a/web/style.css +++ b/web/style.css @@ -415,6 +415,19 @@ textarea { resize: vertical; min-height: 72px; width: 100%; } .cplx-medium { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.07); } .cplx-easy { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.07); } +/* 复审结论徽章(exec_review 闸 / RESULT 区块) */ +.verdict { + display: inline-block; vertical-align: 1px; flex: none; + font-size: 10px; font-weight: 700; letter-spacing: .14em; + padding: 1px 7px; margin-left: 6px; border: 1px solid; +} +.verdict-approve { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.1); } +.verdict-reject { color: var(--red); border-color: var(--red-dim); background: rgba(255,93,93,.1); } +.verdict-none { color: var(--muted); border-color: var(--line); background: rgba(140,150,140,.08); } + +/* 复审 SUMMARY 区块:略加边框突出 */ +.review-summary { border-left: 2px solid var(--line); padding-left: 10px; } + /* 复杂度徽章可点 + 下拉 */ .cplx-wrap { position: relative; display: inline-flex; flex: none; } button.cplx { font-family: var(--mono); cursor: default; } @@ -693,3 +706,51 @@ li.ev-updated { --ev: var(--muted); } 0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); } 100% { background: transparent; box-shadow: none; } } + +/* 徽章组扩展:被阻塞 / 总量 */ +.bdg-blocked { color: var(--amber); border: 1px dashed var(--amber-dim); } +.bdg-total { color: var(--ink); border: 1px solid var(--line); } + +/* ── 归档区 ── */ +#archiveSection { margin-top: 28px; opacity: .82; } +.archive-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 12px; font-size: 12.5px; + border-bottom: 1px dashed var(--line-soft); +} +.archive-row:hover { background: var(--panel); } +.archive-row .t-title { color: var(--muted); } +.archive-subs { flex: none; font-size: 10.5px; color: var(--faint); } +.archive-time { flex: none; font-size: 11px; color: var(--faint); } +.archive-size { font-size: 11px; color: var(--muted); display: flex; gap: 6px; align-items: center; } +.archive-size select { background: var(--panel-2); color: var(--ink); border: 1px solid var(--line); padding: 2px 6px; font-family: var(--mono); } +.archive-pager { display: flex; align-items: center; gap: 12px; padding: 10px 0; justify-content: center; } +.archive-page { font-size: 11px; color: var(--muted); } + +/* 归档详情:状态流转时间线 */ +.tl { display: grid; gap: 0; margin-top: 4px; } +.tl-item { + display: flex; gap: 12px; align-items: baseline; + padding: 5px 0 5px 14px; font-size: 12px; + border-left: 2px solid var(--line); + position: relative; +} +.tl-item::before { + content: ''; position: absolute; left: -4px; top: 11px; + width: 6px; height: 6px; border-radius: 50%; background: var(--green-dim); +} +.tl-time { flex: none; color: var(--faint); font-size: 11px; min-width: 150px; } +.tl-text { color: var(--ink); } +.tl-who { color: var(--muted); font-size: 11px; } +.archive-row { cursor: pointer; } + +/* 顶栏动作不换行(同步 todo / 配置 按钮文字曾被挤成两行) */ +.top-actions { flex-wrap: nowrap; } +.top-actions .btn, .sync-meta { white-space: nowrap; } + +/* 归档分页底栏:尺寸 chip 组替代原生下拉 */ +.archive-size-chips { display: inline-flex; align-items: center; gap: 6px; margin-left: 18px; } +.archive-size-label { font-size: 11px; color: var(--faint); margin-right: 2px; } + +/* 配置面板内的同步行 */ +.cfg-sync-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); align-items: center; gap: 10px; }