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:
Generated
+209
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 repo(main + README)
|
||||
git(repo, ['init', '-b', 'main']);
|
||||
git(repo, ['config', 'user.name', 'maestro-poc']);
|
||||
git(repo, ['config', 'user.email', 'poc@maestro.local']);
|
||||
writeFileSync(join(repo, 'README.md'), '# poc demo\n');
|
||||
git(repo, ['add', '-A']);
|
||||
git(repo, ['commit', '-m', 'init']);
|
||||
log(`临时 repo:${repo}`);
|
||||
log(`临时数据目录:${dataDir}`);
|
||||
|
||||
// 2) 起临时 daemon(:4528,编排器 3s 一轮,定时同步关闭)
|
||||
const daemon = spawn('npx', ['tsx', 'src/daemon/index.ts'], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
MAESTRO_DATA_DIR: dataDir,
|
||||
MAESTRO_PORT: String(PORT),
|
||||
MAESTRO_ORCH_INTERVAL: String(ORCH_INTERVAL),
|
||||
MAESTRO_SYNC_INTERVAL: '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
daemon.stdout.on('data', (d) => process.stdout.write(`[daemon] ${d}`));
|
||||
daemon.stderr.on('data', (d) => process.stderr.write(`[daemon!] ${d}`));
|
||||
|
||||
const cleanup = (keep) => {
|
||||
daemon.kill('SIGTERM');
|
||||
if (!keep) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
} else {
|
||||
log(`保留现场:repo=${repo} dataDir=${dataDir}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 等 daemon 就绪
|
||||
let up = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try { await api('GET', '/api/projects'); up = true; break; } catch { await sleep(500); }
|
||||
}
|
||||
if (!up) throw new Error('daemon 未就绪(:4528)');
|
||||
log('daemon 就绪');
|
||||
|
||||
// 3) 注册项目(auto-easy)+ Easy 任务 + operations
|
||||
const project = await api('POST', '/api/projects', {
|
||||
name: 'poc', repoPath: repo, defaultBranch: 'main', autonomy: 'auto-easy', concurrency: 1,
|
||||
});
|
||||
const task = await api('POST', `/api/projects/${project.id}/tasks`, {
|
||||
title: '在 README.md 追加一行 hello maestro', complexity: 'easy',
|
||||
});
|
||||
await api('POST', `/api/tasks/${task.id}/operations`, {
|
||||
operations: '在仓库根目录的 README.md 末尾追加一行文本「hello maestro」。不要做其他任何改动。',
|
||||
});
|
||||
log(`任务已建:${task.id}(ready,等编排器领取)`);
|
||||
|
||||
// 4) 轮询直到 exec_review / needs_attention / 超时
|
||||
const t0 = Date.now();
|
||||
let cur = null;
|
||||
for (;;) {
|
||||
await sleep(3000);
|
||||
cur = await api('GET', `/api/tasks/${task.id}`);
|
||||
log(`任务状态:${cur.status}`);
|
||||
if (cur.status === 'exec_review' || cur.status === 'needs_attention') break;
|
||||
if (Date.now() - t0 > EXEC_TIMEOUT_MS) throw new Error(`超时(>${EXEC_TIMEOUT_MS / 1000}s),最后状态:${cur.status}`);
|
||||
}
|
||||
|
||||
if (cur.status !== 'exec_review') {
|
||||
const runs = await api('GET', `/api/tasks/${task.id}/runs`);
|
||||
throw new Error(`任务进入 ${cur.status}(非 exec_review)。runs=${JSON.stringify(runs, null, 2)}`);
|
||||
}
|
||||
|
||||
// 5) 断言:result 落库、分支有 commit、文件真的改了、main 未被动过
|
||||
const branch = `maestro/${task.id}`;
|
||||
if (cur.result?.branch !== branch) throw new Error(`result.branch 异常:${JSON.stringify(cur.result)}`);
|
||||
const commits = git(repo, ['log', '--format=%h %s', `main..${branch}`]).trim().split('\n').filter(Boolean);
|
||||
if (commits.length < 1) throw new Error(`分支 ${branch} 上没有 commit`);
|
||||
const readmeOnBranch = git(repo, ['show', `${branch}:README.md`]);
|
||||
if (!readmeOnBranch.includes('hello maestro')) throw new Error(`分支上的 README 未包含「hello maestro」:\n${readmeOnBranch}`);
|
||||
const readmeOnMain = readFileSync(join(repo, 'README.md'), 'utf8');
|
||||
if (readmeOnMain.includes('hello maestro')) throw new Error('main 的 README 被改了(不应自动合并)');
|
||||
if (cur.result.worktree && !existsSync(cur.result.worktree)) throw new Error('worktree 现场不存在');
|
||||
|
||||
log('================ PoC 通过 ✅ ================');
|
||||
log(`任务 → exec_review;分支 ${branch} 共 ${commits.length} 个 commit:`);
|
||||
for (const c of commits) log(` ${c}`);
|
||||
log(`diffSummary: ${cur.result.diffSummary}`);
|
||||
log('main 未被自动合并(符合「绝不自动合并」)');
|
||||
cleanup(false);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error(`[poc] 失败:${e.message}`);
|
||||
cleanup(true);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 全 done;auto-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 全 done;auto-easy 只挑 easy。
|
||||
* 按调度分降序返回(score = 自身分 + 已完成依赖分 + 等待解锁的 blocked 任务分,见 model/scoring.ts)。
|
||||
*/
|
||||
function claimable(project: Project): Array<{ task: Task; score: number }> {
|
||||
const tasks = store.listTasks(project.id);
|
||||
const byId = new Map(tasks.map((t) => [t.id, t]));
|
||||
const parents = new Set(tasks.filter((t) => t.parentId).map((t) => t.parentId as string));
|
||||
const easyOnly = project.autonomy === 'auto-easy';
|
||||
const candidates = tasks.filter((t) => {
|
||||
if (inflight.has(t.id)) return false;
|
||||
if (t.status !== 'ready' && t.status !== 'queued') return false;
|
||||
if (parents.has(t.id)) return false; // 非叶子(容器)跳过
|
||||
if (easyOnly && t.complexity !== 'easy') return false;
|
||||
return t.deps.every((dep) => byId.get(dep)?.status === 'done');
|
||||
});
|
||||
return rankByScore(candidates, tasks);
|
||||
}
|
||||
|
||||
/** 单任务全流程:executing → worktree → run → verify → reviewer(复审,失败不挡) → setResult(summary/verdict) → exec_review / failed(重试) */
|
||||
async function executeTask(project: Project, task: Task): Promise<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;
|
||||
}
|
||||
@@ -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 CC(runner / 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
|
||||
const GIT_TIMEOUT_MS = 60_000;
|
||||
const MAX_BUFFER = 16 * 1024 * 1024;
|
||||
|
||||
/** 在 repoPath 下执行 git(execFile 不走 shell),超时与错误透传(附 stderr)。 */
|
||||
export function git(repoPath: string, args: string[], timeoutMs = GIT_TIMEOUT_MS): Promise<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);
|
||||
}
|
||||
@@ -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
@@ -27,7 +27,7 @@ export const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
speccing: '写方案中',
|
||||
spec_review: '待确认方案',
|
||||
ready: '可执行',
|
||||
blocked: '被依赖阻塞',
|
||||
blocked: '被阻塞',
|
||||
queued: '排队中',
|
||||
executing: '执行中',
|
||||
exec_review: '待审/合',
|
||||
|
||||
+7
-2
@@ -32,12 +32,17 @@ export interface ApprovalRecord {
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** 复审结论:approve=建议通过 / reject=建议拒绝 / null=未复审(复审失败或旧数据) */
|
||||
export type ReviewVerdict = 'approve' | 'reject';
|
||||
|
||||
export interface TaskResult {
|
||||
branch: string | null;
|
||||
worktree: string | null;
|
||||
diffSummary: string | null;
|
||||
commits: string[];
|
||||
prUrl: string | null;
|
||||
summary: string | null; // 复审 summary(markdown),旧数据缺省 null
|
||||
verdict: ReviewVerdict | null; // 复审结论,解析不到 / 复审失败 = null
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
@@ -61,13 +66,13 @@ export interface Task {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type RunKind = 'planner' | 'executor';
|
||||
export type RunKind = 'planner' | 'executor' | 'reviewer';
|
||||
export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
export interface Run {
|
||||
id: Id;
|
||||
taskId: Id;
|
||||
kind: RunKind; // planner(产出拆解/方案)| executor(执行改动)
|
||||
kind: RunKind; // planner(产出拆解/方案)| executor(执行改动)| reviewer(执行后自动复审)
|
||||
worktree: string | null;
|
||||
branch: string | null;
|
||||
status: RunStatus;
|
||||
|
||||
+15
-1
@@ -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
@@ -7,6 +7,7 @@ import {
|
||||
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js';
|
||||
import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
|
||||
import type { Complexity } from '../model/complexity.js';
|
||||
import { rankByScore } from '../model/scoring.js';
|
||||
import {
|
||||
type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL,
|
||||
} from '../model/status.js';
|
||||
@@ -174,9 +175,15 @@ export class Store {
|
||||
}
|
||||
|
||||
if (input.priority !== undefined) assertPriority(input.priority);
|
||||
// deps 必须引用本项目已存在的任务(也因此依赖图天然无环:新任务不可能已被引用)
|
||||
const depsArr = input.deps ?? [];
|
||||
for (const d of depsArr) {
|
||||
const dep = this.getTaskRow(d);
|
||||
if (!dep) throw new StoreError(`依赖任务不存在: ${d}`);
|
||||
if (dep.project_id !== input.projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`);
|
||||
}
|
||||
let status = initialNextStatus(input.complexity);
|
||||
// Easy 直达 ready,但有未完成依赖时落位 blocked
|
||||
const depsArr = input.deps ?? [];
|
||||
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
|
||||
status = 'blocked';
|
||||
}
|
||||
@@ -333,6 +340,26 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务变 done 后的连锁处理:
|
||||
* 1. 放行依赖它的 blocked 任务;
|
||||
* 2. 已拆解容器的子任务全部 done/取消 → 容器自动 done(“深度1整树完成”生命周期闭环),逐级向上。
|
||||
*/
|
||||
private afterDone(projectId: string, taskId: string): void {
|
||||
this.releaseDependents(projectId);
|
||||
let cur = this.getTaskRow(taskId);
|
||||
while (cur?.parent_id) {
|
||||
const parent = this.getTaskRow(cur.parent_id);
|
||||
if (!parent || parent.status !== 'decomposed') break;
|
||||
const kids = this.db.prepare(`SELECT status FROM tasks WHERE parent_id = ?`).all(parent.id) as Array<{ status: string }>;
|
||||
if (!kids.every((k) => k.status === 'done' || k.status === 'cancelled')) break;
|
||||
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), parent.id);
|
||||
this.emit(parent.project_id, parent.id, 'status.changed', { from: 'decomposed', to: 'done', auto: 'children-done' });
|
||||
this.releaseDependents(projectId); // 容器本身可能是别人的依赖
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量依赖对账(幂等):ready 但依赖未满足 → blocked;blocked 但依赖已满足 → ready。
|
||||
* daemon 启动时调用,纠正存量数据。
|
||||
@@ -361,6 +388,22 @@ export class Store {
|
||||
return { blocked: nBlocked, released: nReleased };
|
||||
}
|
||||
|
||||
/**
|
||||
* daemon 启动时的中断现场恢复:上次进程退出时仍 started 的 run 标 failed,
|
||||
* 卡在 executing 的任务转 failed→queued 等编排器重新领取。
|
||||
* 注:中断产生的 failed run 会计入该任务的失败次数(多次中断+真失败可能提前转 needs_attention,可接受)。
|
||||
*/
|
||||
reconcileInterrupted(): { runs: number; tasks: number } {
|
||||
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started'`).all() as RunRow[];
|
||||
for (const r of runs) this.finishRun(r.id, 'failed', { error: 'daemon 重启,执行中断' });
|
||||
const rows = this.db.prepare(`SELECT * FROM tasks WHERE status = 'executing'`).all() as TaskRow[];
|
||||
for (const t of rows) {
|
||||
this.transition(t.id, 'failed', { auto: 'daemon-restart' });
|
||||
this.transition(t.id, 'queued', { auto: 'daemon-restart' });
|
||||
}
|
||||
return { runs: runs.length, tasks: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供导入器:旧系统中已完成的任务直接置 done(绕过执行链路与依赖落位——
|
||||
* 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。
|
||||
@@ -372,7 +415,7 @@ export class Store {
|
||||
if (from === 'done') return this.getTask(taskId)!;
|
||||
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), taskId);
|
||||
this.emit(row.project_id, taskId, 'status.changed', { from, to: 'done', auto: 'import-done', actor });
|
||||
this.releaseDependents(row.project_id);
|
||||
this.afterDone(row.project_id, taskId);
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
@@ -392,7 +435,7 @@ export class Store {
|
||||
this.emit(row.project_id, taskId, 'status.changed', {
|
||||
from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta,
|
||||
});
|
||||
if (actual === 'done') this.releaseDependents(row.project_id);
|
||||
if (actual === 'done') this.afterDone(row.project_id, taskId);
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
@@ -444,7 +487,7 @@ export class Store {
|
||||
});
|
||||
txn();
|
||||
this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null });
|
||||
if (to === 'done') this.releaseDependents(row.project_id); // 完成 → 自动放行依赖它的任务
|
||||
if (to === 'done') this.afterDone(row.project_id, taskId); // 完成 → 放行依赖 + 容器自动收口
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
@@ -494,6 +537,14 @@ export class Store {
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
/** 单任务全量事件(升序):状态流转/审批/运行历史,供归档详情时间线 */
|
||||
listTaskEvents(taskId: string): Event[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM events WHERE task_id = ? ORDER BY at`,
|
||||
).all(taskId) as EventRow[];
|
||||
return rows.map(rowToEvent);
|
||||
}
|
||||
|
||||
listEvents(projectId: string, limit = 200): Event[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM events WHERE project_id = ? ORDER BY at DESC LIMIT ?`,
|
||||
@@ -506,16 +557,14 @@ export class Store {
|
||||
* 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。
|
||||
*/
|
||||
nextExecutable(projectId: string): Task | null {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority ASC, created_at`,
|
||||
).all(projectId) as TaskRow[];
|
||||
for (const r of rows) {
|
||||
if (this.childrenOf(r.id).length > 0) continue; // 非叶子跳过
|
||||
const deps = JSON.parse(r.deps) as string[];
|
||||
const depsDone = deps.every((d) => this.getTaskRow(d)?.status === 'done');
|
||||
if (!depsDone) continue;
|
||||
return rowToTask(r, this.listApprovals(r.id));
|
||||
}
|
||||
return null;
|
||||
const all = this.listTasks(projectId);
|
||||
const byId = new Map(all.map((t) => [t.id, t]));
|
||||
const parents = new Set(all.filter((t) => t.parentId).map((t) => t.parentId as string));
|
||||
const candidates = all.filter((t) =>
|
||||
t.status === 'ready' && !parents.has(t.id) &&
|
||||
t.deps.every((d) => byId.get(d)?.status === 'done'),
|
||||
);
|
||||
const ranked = rankByScore(candidates, all); // 与编排器同一打分(见 model/scoring.ts)
|
||||
return ranked[0]?.task ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('pickModel:project.model 最优先(两个角色、所有复杂度都用它)', () => {
|
||||
withEnv({ MAESTRO_MODEL_HARD: 'claude-haiku-4-5' }, () => {
|
||||
const p = fakeProject('claude-opus-4-6');
|
||||
for (const c of ['easy', 'medium', 'hard'] as const) {
|
||||
assert.equal(pickModel(fakeTask(c), p, 'executor'), 'claude-opus-4-6');
|
||||
assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-opus-4-6');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('pickModel:env 覆盖默认值,且 executor/reviewer 各用各的 env', () => {
|
||||
withEnv({
|
||||
MAESTRO_MODEL_EASY: 'claude-haiku-4-5',
|
||||
MAESTRO_MODEL_REVIEW_HARD: 'claude-fable-5',
|
||||
}, () => {
|
||||
const p = fakeProject();
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-haiku-4-5');
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); // reviewer 不受 executor env 影响
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); // executor hard 默认即 fable
|
||||
});
|
||||
});
|
||||
|
||||
test('pickModel:env 为空白字符串视为未设置', () => {
|
||||
withEnv({ MAESTRO_MODEL_MEDIUM: ' ' }, () => {
|
||||
assert.equal(pickModel(fakeTask('medium'), fakeProject(), 'executor'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
test('isModelError:模型不可用类错误才触发回退', () => {
|
||||
// 应触发
|
||||
assert.ok(isModelError('model claude-fable-5 not_found'));
|
||||
assert.ok(isModelError('Invalid model: claude-x'));
|
||||
assert.ok(isModelError('permission denied for model claude-fable-5'));
|
||||
assert.ok(isModelError('404 model does not exist'));
|
||||
assert.ok(isModelError('The requested model is unavailable'));
|
||||
assert.ok(isModelError('unknown model "claude-fable-5"'));
|
||||
// 不应触发
|
||||
assert.ok(!isModelError('执行超时'));
|
||||
assert.ok(!isModelError('network connection refused'));
|
||||
assert.ok(!isModelError('not found: /tmp/some/file')); // 没提到 model
|
||||
assert.ok(!isModelError('model produced empty output')); // 提到 model 但不是可用性错误
|
||||
});
|
||||
|
||||
test('pickFallbackModel:回退链取第一个 ≠ 失败模型的', () => {
|
||||
assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6']);
|
||||
assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
|
||||
assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6');
|
||||
assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-opus-4-8');
|
||||
});
|
||||
@@ -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:只领 easy,medium ready 不动', async () => {
|
||||
const { store, projectId } = setup('auto-easy', 5);
|
||||
const easy = store.createTask({ projectId, title: 'small', complexity: 'easy' });
|
||||
const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' });
|
||||
store.setSpec(medium.id, '方案');
|
||||
store.transition(medium.id, 'spec_review');
|
||||
store.decide(medium.id, 'accept', 'user'); // medium → ready
|
||||
assert.equal(store.getTask(medium.id)!.status, 'ready');
|
||||
|
||||
const ran: string[] = [];
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
runner: async (task) => { ran.push(task.id); return okRun; },
|
||||
}));
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
|
||||
assert.deepEqual(ran, [easy.id]);
|
||||
assert.equal(store.getTask(easy.id)!.status, 'exec_review');
|
||||
assert.equal(store.getTask(medium.id)!.status, 'ready'); // 不自动跑
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('autonomy=auto-approved:领全部 ready(含 medium),依赖未满足/非叶子不领', async () => {
|
||||
const { store, projectId } = setup('auto-approved', 5);
|
||||
const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' });
|
||||
store.setSpec(medium.id, '方案');
|
||||
store.transition(medium.id, 'spec_review');
|
||||
store.decide(medium.id, 'accept', 'user');
|
||||
// 依赖未 done 的任务:建在 medium 上 → blocked,不可领
|
||||
const dep = store.createTask({ projectId, title: 'after-mid', complexity: 'easy', deps: [medium.id] });
|
||||
assert.equal(store.getTask(dep.id)!.status, 'blocked');
|
||||
|
||||
const ran: string[] = [];
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
runner: async (task) => { ran.push(task.id); return okRun; },
|
||||
}));
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
|
||||
assert.deepEqual(ran, [medium.id]);
|
||||
assert.equal(store.getTask(medium.id)!.status, 'exec_review');
|
||||
assert.equal(store.getTask(dep.id)!.status, 'blocked');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('concurrency=1:同项目同轮只领 1 个,跑完下一轮再领', async () => {
|
||||
const { store, projectId } = setup('auto-approved', 1);
|
||||
const t1 = store.createTask({ projectId, title: 'a', complexity: 'easy' });
|
||||
const t2 = store.createTask({ projectId, title: 'b', complexity: 'easy' });
|
||||
|
||||
const gate = deferred<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_review,summary 记失败原因、verdict=null', async () => {
|
||||
const { store, projectId } = setup('auto-easy');
|
||||
const t = store.createTask({ projectId, title: 'review-broken', complexity: 'easy' });
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
reviewer: async () => { throw new Error('复审 CC 崩了'); },
|
||||
}));
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
|
||||
const done = store.getTask(t.id)!;
|
||||
assert.equal(done.status, 'exec_review'); // 不挡结果闸
|
||||
assert.equal(done.result!.verdict, null);
|
||||
assert.equal(done.result!.summary, '自动复审失败:复审 CC 崩了');
|
||||
|
||||
const runs = store.listRuns(t.id);
|
||||
assert.equal(runs.find((r) => r.kind === 'executor')!.status, 'succeeded');
|
||||
const reviewer = runs.find((r) => r.kind === 'reviewer')!;
|
||||
assert.equal(reviewer.status, 'failed');
|
||||
assert.match(reviewer.error ?? '', /复审 CC 崩了/);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('verify 不过:按失败处理(run failed + 重新入队)', async () => {
|
||||
const { store, projectId } = setup('auto-easy');
|
||||
const t = store.createTask({ projectId, title: 'v', complexity: 'easy' });
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
verify: async () => ({ ok: false, exitCode: 1, logRef: '/tmp/v.log', error: 'verify 失败(exit 1)' }),
|
||||
}));
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
assert.equal(store.getTask(t.id)!.status, 'queued'); // 第一次失败 → 重新入队
|
||||
const runs = store.listRuns(t.id);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].status, 'failed');
|
||||
assert.match(runs[0].error ?? '', /verify 失败/);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test(`失败重试:重试 ${MAX_RETRIES} 次后 → needs_attention(共 ${MAX_RETRIES + 1} 次失败 run)`, async () => {
|
||||
const { store, projectId } = setup('auto-easy');
|
||||
const t = store.createTask({ projectId, title: 'flaky', complexity: 'easy' });
|
||||
|
||||
let attempts = 0;
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
runner: async () => { attempts++; return { ok: false, transcriptRef: null, sessionId: null, error: `boom #${attempts}` }; },
|
||||
}));
|
||||
|
||||
for (let i = 1; i <= MAX_RETRIES; i++) {
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
assert.equal(store.getTask(t.id)!.status, 'queued', `第 ${i} 次失败后应重新入队`);
|
||||
}
|
||||
|
||||
orch.tick(); // 最后一次重试也失败
|
||||
await orch.drain();
|
||||
assert.equal(store.getTask(t.id)!.status, 'needs_attention');
|
||||
assert.equal(attempts, MAX_RETRIES + 1);
|
||||
|
||||
const failed = store.listRuns(t.id).filter((r) => r.status === 'failed');
|
||||
assert.equal(failed.length, MAX_RETRIES + 1);
|
||||
|
||||
orch.tick(); // needs_attention 不会再被领取
|
||||
await orch.drain();
|
||||
assert.equal(attempts, MAX_RETRIES + 1);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('project paused:不领取', async () => {
|
||||
const { store, projectId } = setup('auto-approved');
|
||||
const t = store.createTask({ projectId, title: 'x', complexity: 'easy' });
|
||||
store.patchProject(projectId, { status: 'paused' });
|
||||
let calls = 0;
|
||||
const orch = createOrchestrator(store, noopLog, mockDeps({
|
||||
runner: async () => { calls++; return okRun; },
|
||||
}));
|
||||
orch.tick();
|
||||
await orch.drain();
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(store.getTask(t.id)!.status, 'ready');
|
||||
store.close();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseVerdict, buildReviewPrompt } from '../src/executor/reviewer.js';
|
||||
import type { Project, Task } from '../src/model/types.js';
|
||||
|
||||
test('parseVerdict:approve', () => {
|
||||
const { summary, verdict } = parseVerdict('## 做了什么\n改了 README\n\n## 结论\n建议通过\nVERDICT: approve');
|
||||
assert.equal(verdict, 'approve');
|
||||
assert.equal(summary, '## 做了什么\n改了 README\n\n## 结论\n建议通过'); // VERDICT 行已剔除
|
||||
});
|
||||
|
||||
test('parseVerdict:reject(大小写/前后空白宽容)', () => {
|
||||
const { summary, verdict } = parseVerdict('问题很多\n verdict: REJECT \n');
|
||||
assert.equal(verdict, 'reject');
|
||||
assert.equal(summary, '问题很多');
|
||||
});
|
||||
|
||||
test('parseVerdict:解析不到 = null,summary 原样保留', () => {
|
||||
const { summary, verdict } = parseVerdict('## 结论\n建议通过,但忘了写 VERDICT 行');
|
||||
assert.equal(verdict, null);
|
||||
assert.equal(summary, '## 结论\n建议通过,但忘了写 VERDICT 行');
|
||||
});
|
||||
|
||||
test('parseVerdict:多个 VERDICT 行取最后一个,全部从 summary 剔除', () => {
|
||||
const { summary, verdict } = parseVerdict('VERDICT: approve\n复查后改判\nVERDICT: reject');
|
||||
assert.equal(verdict, 'reject');
|
||||
assert.equal(summary, '复查后改判');
|
||||
});
|
||||
|
||||
test('parseVerdict:VERDICT 行带多余内容不算(如模板原文 approve|reject)', () => {
|
||||
const { verdict } = parseVerdict('VERDICT: approve|reject');
|
||||
assert.equal(verdict, null);
|
||||
});
|
||||
|
||||
test('buildReviewPrompt:含任务说明、执行者自述、diff 指令与固定模板', () => {
|
||||
const task = { id: 'task_1', title: '修复登录', operations: '改 auth.ts', spec: null, plan: null } as Task;
|
||||
const project = { defaultBranch: 'main' } as Project;
|
||||
const wt = { dir: '/tmp/wt', branch: 'maestro/task_1' };
|
||||
const prompt = buildReviewPrompt(task, project, wt, '我改了 auth.ts 并加了测试');
|
||||
|
||||
assert.match(prompt, /修复登录/);
|
||||
assert.match(prompt, /改 auth\.ts/);
|
||||
assert.match(prompt, /我改了 auth\.ts 并加了测试/);
|
||||
assert.match(prompt, /git diff main\.\.\.maestro\/task_1/);
|
||||
for (const section of ['## 做了什么', '## 怎么做的', '## 测试情况', '## Code Review', '## 安全 Review', '## 结论']) {
|
||||
assert.ok(prompt.includes(section), `模板缺少 ${section}`);
|
||||
}
|
||||
assert.match(prompt, /VERDICT: approve\|reject/);
|
||||
assert.match(prompt, /只读权限/);
|
||||
});
|
||||
|
||||
test('buildReviewPrompt:执行者无自述时给占位说明', () => {
|
||||
const task = { id: 'task_2', title: 't', operations: null, spec: null, plan: null } as Task;
|
||||
const project = { defaultBranch: 'main' } as Project;
|
||||
const prompt = buildReviewPrompt(task, project, { dir: '/x', branch: 'maestro/task_2' }, '');
|
||||
assert.match(prompt, /执行者未留下自述/);
|
||||
});
|
||||
+45
-1
@@ -96,7 +96,7 @@ test('exec_review 结果闸:accept → done', () => {
|
||||
s.setOperations(t.id, 'op');
|
||||
s.transition(t.id, 'queued');
|
||||
s.transition(t.id, 'executing');
|
||||
s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null });
|
||||
s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null, summary: null, verdict: null });
|
||||
s.transition(t.id, 'exec_review');
|
||||
const done = s.decide(t.id, 'accept', 'user');
|
||||
assert.equal(done.status, 'done');
|
||||
@@ -164,3 +164,47 @@ test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(
|
||||
assert.equal(r2.blocked + r2.released, 0);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('score 调度:解锁加权 > 链条惯性 > 自身分;nextExecutable 取最高分', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'sc', repoPath: '/tmp/sc-' + Math.random() });
|
||||
// A:P1 无依赖,但有两条 P0 blocked 任务等它 → score = 2 + 3 + 3 = 8
|
||||
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy', priority: 1 });
|
||||
s.createTask({ projectId: p.id, title: 'W1', complexity: 'easy', priority: 0, deps: [a.id] });
|
||||
s.createTask({ projectId: p.id, title: 'W2', complexity: 'easy', priority: 0, deps: [a.id] });
|
||||
// B:P0 无依赖无人等 → score = 3
|
||||
s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', priority: 0 });
|
||||
const next = s.nextExecutable(p.id);
|
||||
assert.equal(next?.title, 'A'); // 解锁两条 P0 的 A(8) 压过孤立 P0 的 B(3)
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('createTask:deps 引用不存在/跨项目任务被拒绝', () => {
|
||||
const s = freshStore();
|
||||
const p1 = s.createProject({ name: 'd1', repoPath: '/tmp/d1-' + Math.random() });
|
||||
const p2 = s.createProject({ name: 'd2', repoPath: '/tmp/d2-' + Math.random() });
|
||||
const other = s.createTask({ projectId: p2.id, title: 'x', complexity: 'easy' });
|
||||
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad', complexity: 'easy', deps: ['tsk_nope'] }), StoreError);
|
||||
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad2', complexity: 'easy', deps: [other.id] }), StoreError);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() });
|
||||
const root = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
|
||||
s.setPlan(root.id, '拆 2 子');
|
||||
s.transition(root.id, 'plan_review');
|
||||
s.decide(root.id, 'accept', 'user'); // decomposed
|
||||
const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: '子1', complexity: 'easy' });
|
||||
const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: '子2', complexity: 'easy' });
|
||||
const finish = (tid) => {
|
||||
s.transition(tid, 'queued'); s.transition(tid, 'executing');
|
||||
s.transition(tid, 'exec_review'); s.decide(tid, 'accept', 'user');
|
||||
};
|
||||
finish(c1.id);
|
||||
assert.equal(s.getTask(root.id).status, 'decomposed'); // 还有子没完,容器不动
|
||||
finish(c2.id);
|
||||
assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done
|
||||
s.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createWorktree, worktreeDiff, removeWorktree, worktreeDirFor, branchFor } from '../src/executor/worktree.js';
|
||||
|
||||
function gitSync(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
/** /tmp 下建一个带 1 个 commit 的真实 git repo(main 分支) */
|
||||
function makeRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'maestro-wt-repo-'));
|
||||
gitSync(repo, ['init', '-b', 'main']);
|
||||
gitSync(repo, ['config', 'user.name', 'maestro-test']);
|
||||
gitSync(repo, ['config', 'user.email', 'test@maestro.local']);
|
||||
writeFileSync(join(repo, 'README.md'), '# demo\n');
|
||||
gitSync(repo, ['add', '-A']);
|
||||
gitSync(repo, ['commit', '-m', 'init']);
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('worktree:create / diff / remove 在真实 git repo 上工作', async (t) => {
|
||||
// worktree 根目录隔离到临时 MAESTRO_DATA_DIR,不碰 ~/.maestro
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data-'));
|
||||
const prevDataDir = process.env.MAESTRO_DATA_DIR;
|
||||
process.env.MAESTRO_DATA_DIR = dataDir;
|
||||
const repo = makeRepo();
|
||||
t.after(() => {
|
||||
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
|
||||
else process.env.MAESTRO_DATA_DIR = prevDataDir;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const taskId = 'tsk_test123';
|
||||
|
||||
// 1) 创建:目录与分支符合约定
|
||||
const wt = await createWorktree(repo, taskId, 'main');
|
||||
assert.equal(wt.branch, branchFor(taskId));
|
||||
assert.equal(wt.dir, worktreeDirFor(repo, taskId));
|
||||
assert.ok(existsSync(join(wt.dir, 'README.md')), 'worktree 应检出 README.md');
|
||||
const head = gitSync(wt.dir, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
||||
assert.equal(head, wt.branch);
|
||||
|
||||
// 2) 空分支 diff:无 commit、无摘要
|
||||
const empty = await worktreeDiff(repo, wt.dir, wt.branch, 'main');
|
||||
assert.equal(empty.commits.length, 0);
|
||||
assert.equal(empty.diffSummary, '');
|
||||
|
||||
// 3) 在 worktree 提交一笔改动 → diff 能看到 commit 与 --stat 摘要
|
||||
writeFileSync(join(wt.dir, 'README.md'), '# demo\nhello maestro\n');
|
||||
gitSync(wt.dir, ['add', '-A']);
|
||||
gitSync(wt.dir, ['commit', '-m', `maestro(${taskId}): tweak readme`]);
|
||||
const diff = await worktreeDiff(repo, wt.dir, wt.branch, 'main');
|
||||
assert.equal(diff.commits.length, 1);
|
||||
assert.ok(diff.commits[0].includes(taskId), `commit 行应含任务 id:${diff.commits[0]}`);
|
||||
assert.ok(diff.diffSummary.includes('README.md'), `diffSummary 应含文件名:${diff.diffSummary}`);
|
||||
|
||||
// 4) 已存在同名 worktree/分支时重建(重试场景):从 main 干净重来
|
||||
const wt2 = await createWorktree(repo, taskId, 'main');
|
||||
assert.equal(wt2.dir, wt.dir);
|
||||
const diff2 = await worktreeDiff(repo, wt2.dir, wt2.branch, 'main');
|
||||
assert.equal(diff2.commits.length, 0, '重建后分支应回到 baseBranch,无旧 commit');
|
||||
|
||||
// 5) remove:目录与注册项移除,主 repo 不受影响
|
||||
await removeWorktree(repo, wt2.dir);
|
||||
assert.ok(!existsSync(wt2.dir), 'worktree 目录应被移除');
|
||||
const list = gitSync(repo, ['worktree', 'list']);
|
||||
assert.ok(!list.includes(taskId), 'git worktree list 不应再含该任务');
|
||||
assert.ok(existsSync(join(repo, 'README.md')), '主 repo 不受影响');
|
||||
});
|
||||
|
||||
test('worktree:错误透传(不存在的 baseBranch)', async (t) => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data2-'));
|
||||
const prevDataDir = process.env.MAESTRO_DATA_DIR;
|
||||
process.env.MAESTRO_DATA_DIR = dataDir;
|
||||
const repo = makeRepo();
|
||||
t.after(() => {
|
||||
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
|
||||
else process.env.MAESTRO_DATA_DIR = prevDataDir;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => createWorktree(repo, 'tsk_badbase', 'no-such-branch'),
|
||||
/git worktree add/,
|
||||
);
|
||||
});
|
||||
+294
-28
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user