feat: Phase2 完整管线——score 调度 + 自动复审 + 模型分级 + 归档与详情

调度:
- model/scoring.ts: score = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性)
  + 等待解锁的 blocked 任务分(解锁加权),编排器与 nextExecutable 同一打分
- createTask 校验 deps 存在且同项目(依赖图天然无环)
- daemon 重启中断自愈: executing 任务标 failed run 后重新入队(reconcileInterrupted)

执行管线:
- executor/cc.ts: 公共 headless CC 执行器(转录/超时/模型回退重试)
- executor/reviewer.ts: 执行后自动复审(只读 CC 审 diff),固定模板 summary
  (做了什么/怎么做/测试/CodeReview/安全Review/结论) + VERDICT 解析
- executor/models.ts: 按复杂度选模型(easy→sonnet/medium→opus/hard→fable5),
  env 可覆盖、project.model 最优先、不可用自动回退链
- runner: 测试/构建命令白名单(npm/go/shellcheck/make/pytest),prompt 要求实跑测试
- TaskResult 加 summary/verdict; RunKind 加 reviewer
- 容器收口: 已拆解 Hard 子任务全 done → 容器自动 done(afterDone 逐级向上)

看板:
- 五徽章组(待审批/待执行/执行中/被阻塞/总量,hover 展开,均不含已完成)
- 归档区: 深度1整树完成沉底,时间倒序分页(10/20/50/100 chip 选择)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(含相关人或事)
- Agent 面板显示调度模式 + 各复杂度实际模型
- 结果闸展示复审 summary + 建议通过/拒绝徽章
- 筛选修复(组选与单选分离、已拆解移出进行中)、同步按钮收进配置面板、
  保存配置自动收起、预览全宽、被依赖阻塞→被阻塞
- API: GET /api/tasks/:id/events(任务级事件时间线)、/api/agents 带 scheduling/models

测试: 49/49(新增 scoring/复审/模型/容器收口/deps 校验/中断恢复)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:46:37 +08:00
parent fa472a06a7
commit f18db021c3
25 changed files with 2215 additions and 56 deletions
+209 -1
View File
@@ -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",
+1
View File
@@ -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",
+147
View File
@@ -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/<taskId> 分支上有 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 repomain + 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();
+9
View File
@@ -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<repoPath>/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 };
+6 -1
View File
@@ -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<void> {
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<void> {
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<void> => {
app.log.info('收到退出信号,关闭中…');
if (syncTimer) clearInterval(syncTimer);
if (orchTimer) clearInterval(orchTimer);
await app.close();
store.close();
process.exit(0);
+224
View File
@@ -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<WorktreeInfo>;
worktreeDiff: (repoPath: string, dir: string, branch: string, baseBranch: string) => Promise<WorktreeDiff>;
}
export interface Orchestrator {
/** 跑一轮领取(同步领取 + 异步执行,不阻塞)。错误只记日志。 */
tick(): void;
/** 等待所有在途执行收尾(测试用) */
drain(): Promise<void>;
/** 在途任务 id(防同任务重复领取) */
readonly inflight: ReadonlyMap<string, string>;
}
/**
* 编排器核心(与定时器解耦,便于测试)。
* 每轮对 status=active 且 autonomy≠manual 的项目:在途数 < concurrency 时领任务——
* ready 叶子(deps 全 doneauto-easy 只领 easy)或 queued(重试/重启遗留)。
* 成功 → setResult + exec_review;失败 → failed → 重试 ≤MAX_RETRIES 次 → needs_attention。
*/
export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: Partial<OrchestratorDeps> = {}): Orchestrator {
const d: OrchestratorDeps = { runner: runTask, reviewer: reviewTask, verify: runVerify, createWorktree, worktreeDiff, ...deps };
const inflight = new Map<string, string>(); // taskId → projectId
const pending = new Set<Promise<void>>();
/**
* 本项目可领取的任务:queued(重试/孤儿)+ ready 叶子且 deps 全 doneauto-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<void> {
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<void> = 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<void> {
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<OrchestratorDeps> = {},
): 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;
}
+132
View File
@@ -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';
/** 转录目录:<MAESTRO_DATA_DIR 或 ~/.maestro>/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<AttemptResult> {
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 CCrunner / reviewer 公用)。
* 模型可用性兜底:失败且错误信息像模型不可用(not_found/invalid/permission 等)时,
* 自动用回退链取一个 ≠ 原模型的模型在同一 run 内重试一次;回退记入 transcript 与 finalText。
*/
export async function runClaude(opts: CCOptions): Promise<CCResult> {
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<void>((resolve) => out.end(resolve));
}
}
+60
View File
@@ -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<ModelRole, Record<Complexity, [env: string, fallback: string]>> = {
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<Complexity, string> {
const out = {} as Record<Complexity, string>;
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;
}
+111
View File
@@ -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<ReviewResult>;
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。
* 转录写 <transcriptDir>/<runId>.jsonl。失败抛错(orchestrator 兜底,不挡任务)。
*/
export async function reviewTask(
task: Task,
project: Project,
worktree: WorktreeInfo,
runId: string,
executorReport: string,
): Promise<ReviewResult> {
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 };
}
+94
View File
@@ -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<RunnerResult>;
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<void> {
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)。
* 流式消息逐行写入 <transcriptDir>/<runId>.jsonl;返回 ok/transcriptRef/sessionId/finalText/error。
* 不抛错:一切失败都折叠进 { ok: false, error }。
*/
export async function runTask(task: Task, project: Project, worktree: WorktreeInfo, runId: string): Promise<RunnerResult> {
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 };
}
+49
View File
@@ -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; // <transcriptDir>/<runId>.verify.log;无 verifyCmd 时为 null
error?: string;
}
/** 校验函数签名(orchestrator 依赖注入点) */
export type VerifyFn = (project: Project, dir: string, runId: string) => Promise<VerifyResult>;
const VERIFY_TIMEOUT_MS = 10 * 60_000; // 10min
/**
* project.verifyCmd 存在则在 worktree 内以 shell 执行;exit 0 = 通过。
* stdout/stderr 写 <transcriptDir>/<runId>.verify.log。无 verifyCmd 视为通过。
*/
export async function runVerify(project: Project, dir: string, runId: string): Promise<VerifyResult> {
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}`,
});
}
});
});
}
+86
View File
@@ -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 下执行 gitexecFile 不走 shell),超时与错误透传(附 stderr)。 */
export function git(repoPath: string, args: string[], timeoutMs = GIT_TIMEOUT_MS): Promise<string> {
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 根目录:<MAESTRO_DATA_DIR 或 ~/.maestro>/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 目录(确定性):<base>/<repo名>/<taskId>/ */
export function worktreeDirFor(repoPath: string, taskId: string): string {
return join(worktreeBase(), basename(repoPath), taskId);
}
/**
* 为任务创建隔离 worktree:分支 maestro/<taskId>,基于 baseBranch。
* 残留的同名 worktree/分支先清理再重建(重试场景从干净的 baseBranch 重新开始)。
*/
export async function createWorktree(repoPath: string, taskId: string, baseBranch: string): Promise<WorktreeInfo> {
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<WorktreeDiff> {
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<void> {
await git(repoPath, ['worktree', 'remove', '--force', dir]);
await git(repoPath, ['worktree', 'prune']).catch(() => undefined);
}
+47
View File
@@ -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<string, Task[]> {
const idx = new Map<string, Task[]>();
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<string, Task>, dependents: Map<string, Task[]>): 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));
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const STATUS_LABEL: Record<TaskStatus, string> = {
speccing: '写方案中',
spec_review: '待确认方案',
ready: '可执行',
blocked: '被依赖阻塞',
blocked: '被阻塞',
queued: '排队中',
executing: '执行中',
exec_review: '待审/合',
+7 -2
View File
@@ -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; // 复审 summarymarkdown),旧数据缺省 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;
+15 -1
View File
@@ -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<TaskResult>;
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,
};
}
+64 -15
View File
@@ -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 但依赖未满足 → blockedblocked 但依赖已满足 → 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;
}
}
+98
View File
@@ -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<string, string>, fn: () => void): void {
const saved = new Map<string, string | undefined>();
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('pickModelproject.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('pickModelenv 覆盖默认值,且 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('pickModelenv 为空白字符串视为未设置', () => {
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');
});
+284
View File
@@ -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<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
let resolve!: (v: T) => void;
const promise = new Promise<T>((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> = {}): 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<void> {
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:只领 easymedium 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<void>();
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_reviewsummary 记失败原因、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();
});
+57
View File
@@ -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('parseVerdictapprove', () => {
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('parseVerdictreject(大小写/前后空白宽容)', () => {
const { summary, verdict } = parseVerdict('问题很多\n verdict: REJECT \n');
assert.equal(verdict, 'reject');
assert.equal(summary, '问题很多');
});
test('parseVerdict:解析不到 = nullsummary 原样保留', () => {
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('parseVerdictVERDICT 行带多余内容不算(如模板原文 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, /执行者未留下自述/);
});
+45 -1
View File
@@ -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() });
// AP1 无依赖,但有两条 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] });
// BP0 无依赖无人等 → 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('createTaskdeps 引用不存在/跨项目任务被拒绝', () => {
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();
});
+92
View File
@@ -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 repomain 分支) */
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('worktreecreate / 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/,
);
});
+294 -28
View File
@@ -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() {
<div class="agent-proj">
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
<span class="agent-proj-meta">并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}</span>
<span class="agent-proj-meta" title="调度:score 优先 = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性) + 等我解锁的任务分(解锁加权),同分先进先出">调度 score 优先</span>
${g.models ? `<span class="agent-proj-meta" title="各复杂度任务实际使用的执行模型(Hard 不可用自动回退)">模型 E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}</span>` : ''}
</div>
${g.active.map((r) => `
<div class="agent-run">
@@ -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(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
@@ -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 `
<div class="archive-row" data-action="archive-detail" data-id="${esc(t.id)}" title="点击查看完整详情">
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
${n ? `<span class="archive-subs">含 ${n} 子任务</span>` : ''}
<span class="spacer"></span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="archive-time" title="${esc(t.updatedAt)}">${fmtRel(t.updatedAt)}</span>
</div>`;
}).join('');
// 底部:翻页 + 每页条数 chip 组(替代原生下拉)
const sizeChips = [10, 20, 50, 100].map((n) =>
`<button type="button" class="fchip ${a.size === n ? 'on' : ''}" data-action="archive-size" data-size="${n}">${n}</button>`,
).join('');
$('#archivePager').innerHTML = `
${pages > 1 ? `
<button class="btn btn-xs" data-action="archive-prev" ${a.page <= 1 ? 'disabled' : ''}> 上一页</button>
<span class="archive-page">第 ${a.page} / ${pages} 页</span>
<button class="btn btn-xs" data-action="archive-next" ${a.page >= pages ? 'disabled' : ''}>下一页 </button>` : ''}
<span class="archive-size-chips"><span class="archive-size-label">每页</span>${sizeChips}</span>`;
}
// ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ──
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>${dt}</dt><dd>${dd}</dd>`;
const attrs = `<dl class="result-kv">
${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))}
</dl>`;
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']]
.filter(([f]) => t[f])
.map(([f, label]) => `<div class="gate-doc-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div>`)
.join('');
const runRows = runs.length ? `<div class="gate-doc-label">执行历史(${runs.length} 次 run</div>
<dl class="result-kv">${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 ? `<br>错误:${esc(x.error)}` : '') +
(x.transcriptRef ? `<br><span class="t-id">转录 ${esc(x.transcriptRef)}</span>` : ''),
)).join('')}</dl>` : '';
const resultBlock = r ? `<div class="gate-doc-label">执行结果</div>
${r.summary ? `<div class="doc md">${mdToHtml(r.summary)}</div>` : ''}
<dl class="result-kv">
${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('<br>')) : ''}
</dl>
${r.diffSummary ? `<pre class="doc">${esc(r.diffSummary)}</pre>` : ''}` : '';
const approvalRows = t.approvals && t.approvals.length ? `<div class="gate-doc-label">审批记录</div>
<dl class="result-kv">${t.approvals.map((a) => kv(
`${GATE_LABEL[a.gate] || esc(a.gate)} · ${a.action === 'accept' ? '接受' : '驳回'}`,
`审批人 ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `<br>意见:${esc(a.reason)}` : ''}`,
)).join('')}</dl>` : '';
const timeline = events.length ? `<div class="gate-doc-label">状态流转时间线</div>
<div class="tl">${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 `<div class="tl-item">
<span class="tl-time">${fmtFull(e.at)}</span>
<span class="tl-text">${esc(text)}</span>
${who ? `<span class="tl-who">${esc(who)}</span>` : ''}
</div>`;
}).join('')}</div>` : '';
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="archive-modal-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">归档详情</span>
<span class="preview-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="archive-modal-close" title="关闭(Esc">✕</button>
</div>
<div class="preview-body">
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
</div>
</div>`;
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
function renderPreview() {
const root = $('#previewRoot');
@@ -459,9 +643,24 @@ function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
}
function childrenMap() {
// 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰)
function verdictBadge(v) {
if (v === 'approve') return `<span class="verdict verdict-approve">建议通过</span>`;
if (v === 'reject') return `<span class="verdict verdict-reject">建议拒绝</span>`;
return `<span class="verdict verdict-none">未复审</span>`;
}
// 复审 SUMMARY 区块(label + verdict 徽章 + markdown 渲染),无 summary 且未复审时返回空
function reviewSummaryBlock(r) {
if (!r) return '';
const label = `<div class="gate-doc-label">复审 SUMMARY ${verdictBadge(r.verdict)}</div>`;
if (r.summary) return `${label}<div class="doc md review-summary">${mdToHtml(r.summary)}</div>`;
return `${label}<pre class="doc empty">(无复审记录)</pre>`;
}
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() {
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
).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) =>
`<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`,
).join('');
if (sts.length === 1) return `<span class="fgroup">${members}</span>`;
const gCls = f.statusGroups.has(gi) ? 'on' : '';
return `<span class="fgroup">
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}">${name}</button>
${sts.map((s) => `<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`).join('')}
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}" title="按整组筛选">${name}</button>
${members}
</span>`;
}).join('');
const active = filterActive();
@@ -533,7 +768,12 @@ function renderTree() {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>还没有任务 —— 点上方「+ 新建任务」开始</div>`;
return;
}
const m = childrenMap();
const alive = aliveTasks();
if (!alive.length) {
root.innerHTML = `<div class="tree-empty"><b>ALL DONE</b>全部任务已完成归档(见页面底部归档区)</div>`;
return;
}
const m = childrenMap(alive);
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>没有任务命中当前筛选 —— 放宽条件或「清除筛选」</div>`;
@@ -602,7 +842,8 @@ function renderDetail(t) {
if (t.result) {
const r = t.result;
parts.push(`
<div><div class="detail-label">RESULT · 执行结果</div>
<div><div class="detail-label">RESULT · 执行结果 ${verdictBadge(r.verdict)}</div>
${r.summary ? `<div class="doc md review-summary">${mdToHtml(r.summary)}</div>` : ''}
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
@@ -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;
}
+22 -6
View File
@@ -41,19 +41,23 @@
<div class="spacer"></div>
<div id="badgeGroup" class="badge-group" hidden>
<span id="bdgGate" class="bdg bdg-gate" title="等待你裁决的审批闸">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">待审批</span>
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">待审批</span>
</span>
<span id="bdgReady" class="bdg bdg-ready" title="叶子任务 · 可执行 · 依赖全部完成">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行</span>
<span id="bdgReady" class="bdg bdg-ready" title="可执行(依赖已满足)+ 排队中">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行</span>
</span>
<span id="bdgRun" class="bdg bdg-run" title="agent 正在执行">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行中</span>
</span>
<span id="bdgBlocked" class="bdg bdg-blocked" title="被依赖阻塞(依赖完成后自动放行)">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">被阻塞</span>
</span>
<span id="bdgTotal" class="bdg bdg-total" title="未完成任务总量(不含 done / 已拆解容器 / 取消)">
<span class="bdg-ico">Σ</span><span class="bdg-n">0</span><span class="bdg-label">总量</span>
</span>
</div>
<div id="topActions" class="top-actions" hidden>
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
<button class="btn btn-xs" data-action="toggle-config" title="项目配置(并发 / 工作模式)">⚙ 配置</button>
<button class="btn btn-xs" data-action="toggle-config" title="项目配置(并发 / 工作模式 / todo 同步)">⚙ 配置</button>
</div>
</header>
@@ -73,6 +77,10 @@
<button class="btn btn-solid" data-action="save-config">保存配置</button>
<button class="btn" data-action="toggle-config">收起</button>
</div>
<div class="form-row cfg-sync-row">
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
</div>
</section>
<section id="agentSection" class="agent-section">
@@ -136,6 +144,13 @@
<div id="taskTree"></div>
</section>
<!-- ════════ 已归档(done / 取消,时间倒序分页) ════════ -->
<section id="archiveSection" hidden>
<div class="sec-head"><span class="head-mark"></span>已归档 · <span id="archiveCount">0</span></div>
<div id="archiveList"></div>
<div id="archivePager" class="archive-pager"></div>
</section>
</main>
<!-- ════════ 右栏:事件流 ════════ -->
@@ -170,6 +185,7 @@
</div>
<div id="previewRoot" class="preview-root" hidden></div>
<div id="archiveModalRoot" class="preview-root" hidden></div>
<div id="toastRoot" class="toast-root"></div>
<script src="app.js"></script>
</body>
+61
View File
@@ -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; }