Compare commits
8 Commits
1c62059424
...
b48d4d8135
| Author | SHA1 | Date | |
|---|---|---|---|
| b48d4d8135 | |||
| 5c68bb9324 | |||
| af690701bf | |||
| 5a609a8675 | |||
| 8b494e92aa | |||
| aca8f1b18b | |||
| 0569423b7d | |||
| 7884182d2b |
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAESTRO · 任务调度台</title>
|
||||
<!-- Markdown 渲染(方案/复审/任务树 SPEC 用,window 全局,先于 module 脚本加载)-->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+11
-2
@@ -54,6 +54,13 @@ export function buildTaskTree(flat) {
|
||||
if (pid && byId.has(pid)) byId.get(pid).children.push(node);
|
||||
else roots.push(node);
|
||||
});
|
||||
// 冻结标记:父任务尚未批准拆解(plan_review/analyzing)时,子任务虽落在 speccing/ready/blocked,
|
||||
// 实际被编排器闸冻结、不会领取执行。前端据此改显「待拆解确认」,避免误读成「写方案中/可执行」。
|
||||
byId.forEach((node) => {
|
||||
const pid = node._raw.parentId;
|
||||
const ps = pid && byId.has(pid) ? byId.get(pid)._raw.status : null;
|
||||
node.frozen = ps === 'plan_review' || ps === 'analyzing';
|
||||
});
|
||||
// 子节点按优先级(P0 在前)再按创建时间排序
|
||||
const sortKids = (n) => {
|
||||
n.children.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
|
||||
@@ -76,13 +83,15 @@ export function buildTaskTree(flat) {
|
||||
return { active, archived };
|
||||
}
|
||||
|
||||
// ── 审批闸卡片 ─────────────────────────────────────────
|
||||
// ── 「需你处理」卡片:审批闸(plan/spec/exec) + 需人工(attention) ────────────
|
||||
export function adaptApproval(tk, projName) {
|
||||
const gate = tk.status.replace('_review', ''); // plan | spec | exec
|
||||
// needs_attention 不是审批闸,是卡住态——并入同区但操作不同(requeue/接管/取消,非 accept/reject)
|
||||
const gate = tk.status === 'needs_attention' ? 'attention' : tk.status.replace('_review', ''); // plan | spec | exec | attention
|
||||
const r = tk.result || {};
|
||||
let doc;
|
||||
if (gate === 'plan') doc = tk.plan;
|
||||
else if (gate === 'spec') doc = tk.spec;
|
||||
else if (gate === 'attention') doc = tk.lastRunError || tk.spec || tk.plan || '(任务卡住,无错误详情——可能为执行前重评估 / 合并冲突)';
|
||||
else doc = r.summary || r.diffSummary || tk.operations; // exec:优先 code review 报告(做了什么/怎么做/测试/逐条 review)
|
||||
const out = {
|
||||
id: tk.id, gate, taskId: tk.id, title: tk.title,
|
||||
|
||||
+7
-4
@@ -300,19 +300,22 @@ export function App() {
|
||||
const project = projects.find((p) => p.id === currentId) || projects[0];
|
||||
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
|
||||
const { active: tasks, archived } = buildTaskTree(tasksRaw);
|
||||
// 「需你处理」= 三个审批闸 + needs_attention(卡住待人工)。后者并入同区但操作不同。
|
||||
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
|
||||
const gates = approvalsRaw
|
||||
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
|
||||
.filter((a) => a.projectId === currentId && (REVIEW.has(a.status) || a.status === 'needs_attention'))
|
||||
.map((a) => {
|
||||
const g = adaptApproval(a, projNameOf(a.projectId));
|
||||
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
|
||||
if (g.gate === 'plan') {
|
||||
g.subtasks = tasksRaw
|
||||
.filter((tk) => tk.parentId === a.id)
|
||||
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status }));
|
||||
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status, scopeFiles: tk.scopeFiles || [] }));
|
||||
}
|
||||
return g;
|
||||
});
|
||||
})
|
||||
// 审批闸在前、需人工在后
|
||||
.sort((x, y) => (x.gate === 'attention' ? 1 : 0) - (y.gate === 'attention' ? 1 : 0));
|
||||
const agents = adaptActiveAgents(agentsResp, currentId);
|
||||
const events = eventsRaw.map(adaptEvent);
|
||||
const quota = adaptQuota(usage);
|
||||
@@ -423,7 +426,7 @@ export function App() {
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} taskOps={taskOps} onTakeover={takeover} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} taskOps={taskOps} t={t} />
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,11 @@ import '@ds/_ds_bundle.js';
|
||||
// 文案 i18n(真实数据由 src/api.js + src/adapt.js 提供,不再用 data.js mock)
|
||||
import '@ds/ui_kits/console/i18n.js';
|
||||
|
||||
// 共享 Markdown 渲染组件(window.MaestroRichDoc)——须在引用它的 GateSection/TaskTree 之前
|
||||
import '@ds/ui_kits/console/RichDoc.jsx';
|
||||
// 共享 transcript 回放组件(window.MaestroTranscript)——拆解评审「拆解轨迹」等用
|
||||
import '@ds/ui_kits/console/Transcript.jsx';
|
||||
|
||||
// 六个整屏 surface(保持 design/ 源文件不动,作为单一真相源)
|
||||
import '@ds/ui_kits/console/Sidebar.jsx';
|
||||
import '@ds/ui_kits/console/EventPanel.jsx';
|
||||
|
||||
@@ -1,32 +1,4 @@
|
||||
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
|
||||
function mdInline(text) {
|
||||
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0; let m; let key = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
|
||||
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
function mdBody(body) {
|
||||
const out = []; let key = 0;
|
||||
for (const raw of body.split('\n')) {
|
||||
const t = raw.trim();
|
||||
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
|
||||
if (/^[-*]\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
|
||||
} else if (/^#{1,4}\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
|
||||
} else {
|
||||
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── 报告渲染:把 markdown 按「## 章节」切成可折叠卡片,章节内容交 window.MaestroRichDoc 渲染 ──
|
||||
function mdSections(md) {
|
||||
if (!md) return [];
|
||||
const out = []; const lines = md.split('\n');
|
||||
@@ -48,7 +20,7 @@ function ReportSectionCard({ title, body, t, defaultOpen }) {
|
||||
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
|
||||
{open ? <div style={{ padding: '11px 14px' }}><window.MaestroRichDoc md={body} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +72,85 @@ function FileDiffs({ taskId, t }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 单段复审报告卡片:默认折叠,标题显示复审类型 + 裁决徽章,点击展开看完整报告(markdown)。
|
||||
function ReviewReportCard({ label, verdict, report, t, first }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const vb = verdict === 'reject' ? { c: 'var(--red)', bd: 'var(--red-dim)', txt: '✗ ' + t.vReject }
|
||||
: verdict === 'approve' ? { c: 'var(--green)', bd: 'var(--green-dim)', txt: '✓ ' + t.vApprove } : null;
|
||||
return (
|
||||
<div style={{ marginTop: first ? 0 : 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', cursor: 'pointer', userSelect: 'none', background: 'var(--panel-2)' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.14em', color: 'var(--muted)' }}>{label}</span>
|
||||
{vb ? <span style={{ fontSize: 10, fontWeight: 700, color: vb.c, border: '1px solid ' + vb.bd, borderRadius: 3, padding: '0 6px' }}>{vb.txt}</span> : null}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--faint)' }}>{open ? t.gateCollapse : t.gateExpand}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 13px' }}><window.MaestroRichDoc md={report} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 需人工卡片:拉最近的 reviewer / security run 的「完整复审报告」(脱敏 transcript 末块=最终报告),markdown 渲染——
|
||||
// 替代被截断到 200 字的 lastRunError,把驳回原因罗列清楚。无复审报告(执行崩溃/冲突/重评估等)则回退显示 lastRunError。
|
||||
function ReviewReports({ taskId, fallback, t }) {
|
||||
const [st, setSt] = React.useState({ loading: true, reports: null });
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : []);
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
const latest = (kind) => list.filter((r) => r.kind === kind)
|
||||
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0];
|
||||
const out = [];
|
||||
for (const [kind, label] of [['reviewer', t.lblCodeReview], ['security', t.lblSecurity]]) {
|
||||
const run = latest(kind);
|
||||
if (!run) continue;
|
||||
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : null);
|
||||
const texts = ((d && d.messages) || []).filter((m) => m.type === 'text' && m.text && m.text.trim());
|
||||
if (texts.length) {
|
||||
const report = texts[texts.length - 1].text;
|
||||
const vm = /VERDICT:\s*(approve|reject)/i.exec(report);
|
||||
out.push({ label, report, verdict: vm ? vm[1].toLowerCase() : null });
|
||||
}
|
||||
}
|
||||
if (alive) setSt({ loading: false, reports: out });
|
||||
} catch (e) { if (alive) setSt({ loading: false, reports: [] }); }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [taskId]);
|
||||
|
||||
if (st.loading) return <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '4px 2px' }}>…</div>;
|
||||
if (st.reports && st.reports.length) {
|
||||
return (
|
||||
<div>
|
||||
{st.reports.map((r, i) => (
|
||||
<ReviewReportCard key={i} label={r.label} verdict={r.verdict} report={r.report} t={t} first={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return fallback ? <window.MaestroRichDoc md={fallback} /> : null;
|
||||
}
|
||||
|
||||
// 拆解评审「拆解轨迹」:默认折叠,点击展开后懒加载该任务 planner run 的完整 transcript(表格/分析+工具调用)。
|
||||
function GateTranscript({ taskId, t }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em' }}>{t.planTranscript}</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<div style={{ marginTop: 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 12px' }}>
|
||||
<window.MaestroTranscript taskId={taskId} kind="planner" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全屏预览:读完整方案 + 就地裁决
|
||||
function GatePreview({ item, t, onDecide, onClose }) {
|
||||
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
@@ -150,9 +201,7 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
</div>
|
||||
) : null}
|
||||
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
|
||||
{/^##\s/m.test(item.doc || '')
|
||||
? <ReportCards md={item.doc} t={t} />
|
||||
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
|
||||
{item.doc ? <ReportCards md={item.doc} t={t} /> : null}
|
||||
{item.gate === 'exec' && rv && rv.securitySummary ? (
|
||||
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
|
||||
) : null}
|
||||
@@ -179,17 +228,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
<ComplexityBadge complexity={s.complexity} />
|
||||
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
|
||||
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
|
||||
{StatusChip ? <StatusChip status="paused" label={t.frozenPending} /> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
|
||||
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
|
||||
</div>
|
||||
{s.scopeFiles && s.scopeFiles.length ? (
|
||||
<div style={{ marginTop: 5, paddingLeft: 31, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||
<span style={{ flex: 'none', fontSize: 11.5, color: 'var(--faint)' }}>{t.scopeFiles}:</span>
|
||||
{s.scopeFiles.map((f) => (
|
||||
<span key={f} style={{ fontSize: 10.5, fontFamily: 'var(--mono)', color: 'var(--cyan)', background: 'var(--panel-2)', border: '1px solid var(--line-soft)', borderRadius: 3, padding: '1px 6px' }}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{item.gate === 'plan' ? <GateTranscript taskId={item.taskId} t={t} /> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
{rejecting ? (
|
||||
@@ -212,8 +270,8 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 审批闸区:斜纹条 + GateCard 列表(accept/reject + 驳回必填理由)
|
||||
function GateSection({ approvals, onDecide, t }) {
|
||||
// 「需你处理」区:审批闸(accept/reject + 驳回必填理由)+ 需人工(requeue/接管/取消)
|
||||
function GateSection({ approvals, onDecide, taskOps, onTakeover, t }) {
|
||||
const { GateCard, GateStripe, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(null);
|
||||
const [reason, setReason] = React.useState('');
|
||||
@@ -233,24 +291,36 @@ function GateSection({ approvals, onDecide, t }) {
|
||||
const isFolded = folded.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ marginBottom: 12 }}>
|
||||
<GateCard gate={a.gate} kindLabel={t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.doc}
|
||||
<GateCard gate={a.gate} kindLabel={a.gate === 'attention' ? t.gateNeedsHuman : t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.gate === 'attention' ? undefined : a.doc}
|
||||
collapsed={isFolded} foldable onToggleFold={() => toggleFold(a.id)}
|
||||
foldTitle={isFolded ? t.gateExpand : t.gateCollapse}
|
||||
onHeaderDoubleClick={() => setPreview(a)} headerTitle={t.gateDblTip}
|
||||
headerExtra={<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
onHeaderDoubleClick={a.gate === 'attention' ? undefined : () => setPreview(a)} headerTitle={a.gate === 'attention' ? null : t.gateDblTip}
|
||||
headerExtra={a.gate === 'attention' ? null : (<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
style={{ fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1, cursor: 'pointer', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 7px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--violet)'; e.currentTarget.style.borderColor = 'var(--violet-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>)}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
a.gate === 'attention' ? (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => taskOps && taskOps.requeue(a.taskId)}>{t.gRequeue}</Button>
|
||||
{onTakeover ? <Button variant="ghost" onClick={() => onTakeover({ id: a.taskId, title: a.title })}>{t.gTakeover}</Button> : null}
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="reject" size="xs" onClick={() => taskOps && taskOps.cancel(a.taskId)}>{t.gCancelTask}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
)
|
||||
}>
|
||||
{rejecting === a.id ? (
|
||||
{a.gate === 'attention' ? (
|
||||
<ReviewReports taskId={a.taskId} fallback={a.doc} t={t} />
|
||||
) : rejecting === a.id ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
<Button variant="reject" disabled={!reason.trim()}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 共享 Markdown 渲染:marked 解析 + DOMPurify 净化 + highlight.js 代码高亮。
|
||||
// 挂 window.MaestroRichDoc,供 GateSection / TaskTree 等在 vite app 与 standalone demo 两环境共用。
|
||||
// 依赖 window.marked / window.DOMPurify(必需,CDN 全局)+ window.hljs(可选,缺则代码块不着色)。
|
||||
function ensureRichDocCss() {
|
||||
if (document.getElementById('maestro-richdoc-css')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'maestro-richdoc-css';
|
||||
s.textContent = `
|
||||
.m-richdoc { font-size: 12.8px; line-height: 1.7; color: var(--ink); word-break: break-word; }
|
||||
.m-richdoc > :first-child { margin-top: 0; }
|
||||
.m-richdoc > :last-child { margin-bottom: 0; }
|
||||
.m-richdoc h1, .m-richdoc h2, .m-richdoc h3, .m-richdoc h4 { font-weight: 700; line-height: 1.3; margin: 16px 0 8px; letter-spacing: .01em; }
|
||||
.m-richdoc h1 { font-size: 1.32em; color: var(--ink); border-bottom: 1px solid var(--line-soft); padding-bottom: 5px; }
|
||||
.m-richdoc h2 { font-size: 1.16em; color: var(--green); }
|
||||
.m-richdoc h3 { font-size: 1.05em; color: var(--cyan); }
|
||||
.m-richdoc h4 { font-size: 1em; color: var(--muted); }
|
||||
.m-richdoc p { margin: 7px 0; }
|
||||
.m-richdoc ul, .m-richdoc ol { margin: 7px 0; padding-left: 22px; }
|
||||
.m-richdoc li { margin: 3px 0; }
|
||||
.m-richdoc li::marker { color: var(--green); }
|
||||
.m-richdoc a { color: var(--cyan); text-decoration: none; border-bottom: 1px solid var(--cyan-dim); }
|
||||
.m-richdoc a:hover { border-bottom-color: var(--cyan); }
|
||||
.m-richdoc strong { color: var(--ink); font-weight: 700; }
|
||||
.m-richdoc em { color: var(--muted); }
|
||||
.m-richdoc code { font-family: var(--mono); font-size: .9em; color: var(--cyan); background: var(--panel-2); border-radius: 3px; padding: 1.5px 5px; }
|
||||
.m-richdoc pre { background: var(--bg-deep); border: 1px solid var(--line-soft); border-radius: 5px; padding: 11px 13px; overflow-x: auto; margin: 9px 0; }
|
||||
.m-richdoc pre code { display: block; font-family: var(--mono); font-size: 11.8px; line-height: 1.55; color: var(--ink); background: none; border: 0; padding: 0; }
|
||||
.m-richdoc blockquote { margin: 9px 0; padding: 2px 13px; border-left: 3px solid var(--line); color: var(--muted); }
|
||||
.m-richdoc table { border-collapse: collapse; margin: 9px 0; font-size: .95em; display: block; overflow-x: auto; }
|
||||
.m-richdoc th, .m-richdoc td { border: 1px solid var(--line); padding: 5px 10px; text-align: left; }
|
||||
.m-richdoc th { background: var(--panel-2); color: var(--ink); font-weight: 700; }
|
||||
.m-richdoc hr { border: 0; border-top: 1px solid var(--line-soft); margin: 14px 0; }
|
||||
.m-richdoc img { max-width: 100%; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function MaestroRichDoc({ md }) {
|
||||
const ref = React.useRef(null);
|
||||
ensureRichDocCss();
|
||||
const ready = typeof window !== 'undefined' && !!window.marked && !!window.DOMPurify;
|
||||
const html = React.useMemo(() => {
|
||||
if (!ready || !md) return null;
|
||||
try {
|
||||
const raw = window.marked.parse(String(md), { gfm: true, breaks: false });
|
||||
return window.DOMPurify.sanitize(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [md, ready]);
|
||||
React.useEffect(() => {
|
||||
if (html && ref.current && window.hljs) {
|
||||
ref.current.querySelectorAll('pre code').forEach((el) => {
|
||||
try { window.hljs.highlightElement(el); } catch (e) { /* noop */ }
|
||||
});
|
||||
}
|
||||
}, [html]);
|
||||
// 兜底:库未就绪或解析失败 → 原文 pre-wrap,永不崩
|
||||
if (html == null) {
|
||||
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, color: 'var(--ink)' }}>{md || ''}</pre>;
|
||||
}
|
||||
return <div ref={ref} className="m-richdoc" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
window.MaestroRichDoc = MaestroRichDoc;
|
||||
@@ -234,7 +234,9 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
|
||||
) : null}
|
||||
<span style={{ fontSize: 10.5, color: task.prio === 'P0' ? 'var(--amber)' : 'var(--faint)', flex: 'none' }}>{task.prio}</span>
|
||||
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
|
||||
<StatusChip status={task.status} label={t.status[task.status]} />
|
||||
{task.frozen
|
||||
? <StatusChip status="paused" label={t.frozenPending} />
|
||||
: <StatusChip status={task.status} label={t.status[task.status]} />}
|
||||
</div>
|
||||
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} taskOps={taskOps} /> : null}
|
||||
{kids.length && open ? (
|
||||
@@ -315,7 +317,7 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
{label ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
|
||||
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)', borderRadius: 4 }}>{text}</pre>
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 14px', borderRadius: 4 }}><window.MaestroRichDoc md={text} /></div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', border: '1px dashed var(--line)', borderRadius: 6, color: 'var(--faint)', fontStyle: 'italic', fontSize: 12 }}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 历史 transcript 回放:拉某任务指定 kind 的 run 的转录(/api/tasks/:id/runs → /api/runs/:id/transcript),
|
||||
// 渲染成结构化行(text / tool_use / tool_result / result)。挂 window.MaestroTranscript,供拆解评审「拆解轨迹」等处复用。
|
||||
// 转录已是脱敏 + 截断的扁平 schema:{type, text?, name?, input?, isError?}(见 src/executor/transcript.ts)。
|
||||
function TcLine({ m }) {
|
||||
if (m.type === 'tool_use') {
|
||||
return <div style={{ padding: '3px 0' }}><span style={{ color: 'var(--cyan)', fontWeight: 600 }}>⚙ {m.name}</span>{m.input ? <span style={{ color: 'var(--muted)' }}> {m.input}</span> : null}</div>;
|
||||
}
|
||||
if (m.type === 'tool_result') {
|
||||
return <div style={{ padding: '1px 0 5px 16px', color: m.isError ? 'var(--red)' : 'var(--faint)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>↩ {m.text || '(空)'}</div>;
|
||||
}
|
||||
if (m.type === 'result') return <div style={{ padding: '3px 0', color: 'var(--green)' }}>■ {m.text || 'done'}</div>;
|
||||
return <div style={{ padding: '3px 0', color: 'var(--ink)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{m.text}</div>;
|
||||
}
|
||||
|
||||
function MaestroTranscript({ taskId, kind }) {
|
||||
const [state, setState] = React.useState({ loading: true, msgs: null, err: false });
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
setState({ loading: true, msgs: null, err: false });
|
||||
(async () => {
|
||||
try {
|
||||
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : Promise.reject(new Error('http')));
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
const run = (kind ? list.filter((r) => r.kind === kind) : list)
|
||||
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0] || list[0];
|
||||
if (!run) { if (alive) setState({ loading: false, msgs: [], err: false }); return; }
|
||||
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : Promise.reject(new Error('http')));
|
||||
const msgs = Array.isArray(d) ? d : (d.messages || []);
|
||||
if (alive) setState({ loading: false, msgs, err: false });
|
||||
} catch (e) {
|
||||
if (alive) setState({ loading: false, msgs: null, err: true });
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [taskId, kind]);
|
||||
|
||||
const hint = (txt) => <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '8px 2px' }}>{txt}</div>;
|
||||
if (state.loading) return hint('… 加载轨迹');
|
||||
if (state.err) return hint('轨迹取不到');
|
||||
if (!state.msgs || !state.msgs.length) return hint('无轨迹记录');
|
||||
return (
|
||||
<div style={{ maxHeight: 440, overflowY: 'auto', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, padding: '4px 2px' }}>
|
||||
{state.msgs.map((m, i) => <TcLine key={m.seq != null ? m.seq : i} m={m} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.MaestroTranscript = MaestroTranscript;
|
||||
@@ -35,7 +35,7 @@ window.MAESTRO_I18N = {
|
||||
fgroups: { todo: '待办', doing: '进行中', gate: '待审批', bad: '异常', hold: '挂起', done: '完成' },
|
||||
fStatus: '状态', fAssignee: '执行者', assignees: { agent: '智能体', human: '人工' },
|
||||
fullRead: '全屏阅读', depsLabel: 'DEPS · 依赖(全部完成才可执行)', depDone: '完成', depWait: '等待',
|
||||
gateSection: '审批闸', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
|
||||
gateSection: '需你处理', gateNeedsHuman: '需人工', gRequeue: '↻ 重投', gTakeover: '⌨ 接管', gCancelTask: '⊘ 取消', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
|
||||
rejectPlaceholder: '改进意见(必填)',
|
||||
taskSection: '任务树', newTask: '+ 新建任务', titleLabel: '标题', titlePlaceholder: '要做什么',
|
||||
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
|
||||
@@ -49,7 +49,7 @@ window.MAESTRO_I18N = {
|
||||
gatePassed: ' 通过',
|
||||
status: { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被依赖阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消' },
|
||||
gates: { plan: '拆解评审', spec: '方案评审', exec: '结果评审' },
|
||||
gateSubtasks: '拆解出的子任务', noDeps: '无依赖', dependsOn: '依赖',
|
||||
gateSubtasks: '拆解出的子任务', frozenPending: '待拆解确认', scopeFiles: '文件范围', planTranscript: '拆解轨迹(agent 执行记录)', noDeps: '无依赖', dependsOn: '依赖',
|
||||
lblCodeReview: '代码复审', lblSecurity: '安全审计', lblChanges: '改动', vApprove: '通过', vReject: '驳回', vNone: '无结论',
|
||||
eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步' },
|
||||
},
|
||||
@@ -85,7 +85,7 @@ window.MAESTRO_I18N = {
|
||||
fgroups: { todo: 'To do', doing: 'In progress', gate: 'Pending review', bad: 'Issues', hold: 'On hold', done: 'Done' },
|
||||
fStatus: 'Status', fAssignee: 'Assignee', assignees: { agent: 'Agent', human: 'Human' },
|
||||
fullRead: 'Read fullscreen', depsLabel: 'DEPS (all must complete to run)', depDone: 'done', depWait: 'waiting',
|
||||
gateSection: 'Approval gates', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
|
||||
gateSection: 'Needs you', gateNeedsHuman: 'Needs human', gRequeue: '↻ Requeue', gTakeover: '⌨ Take over', gCancelTask: '⊘ Cancel', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
|
||||
rejectPlaceholder: 'Improvement feedback (required)',
|
||||
taskSection: 'Task tree', newTask: '+ New task', titleLabel: 'Title', titlePlaceholder: 'What needs doing',
|
||||
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
|
||||
@@ -99,7 +99,7 @@ window.MAESTRO_I18N = {
|
||||
gatePassed: ' approved',
|
||||
status: { init: 'New', analyzing: 'Analyzing', plan_review: 'Plan review', decomposed: 'Decomposed', speccing: 'Drafting spec', spec_review: 'Spec review', ready: 'Ready', blocked: 'Blocked', queued: 'Queued', executing: 'Executing', exec_review: 'Review & merge', failed: 'Failed', needs_attention: 'Needs attention', done: 'Done', paused: 'Paused', cancelled: 'Cancelled' },
|
||||
gates: { plan: 'PLAN REVIEW', spec: 'SPEC REVIEW', exec: 'EXEC REVIEW' },
|
||||
gateSubtasks: 'Decomposed subtasks', noDeps: 'no deps', dependsOn: 'deps',
|
||||
gateSubtasks: 'Decomposed subtasks', frozenPending: 'awaiting approval', scopeFiles: 'files', planTranscript: 'Decompose trace', noDeps: 'no deps', dependsOn: 'deps',
|
||||
lblCodeReview: 'Code review', lblSecurity: 'Security', lblChanges: 'Changes', vApprove: 'Approve', vReject: 'Reject', vNone: 'No verdict',
|
||||
eventTypes: { 'task.created': 'Task created', 'task.updated': 'Task updated', 'status.changed': 'Status changed', 'approval.requested': 'Approval requested', 'approval.granted': 'Approval granted', 'approval.rejected': 'Approval rejected', 'run.started': 'Run started', 'run.finished': 'Run finished', 'project.synced': 'Todo synced' },
|
||||
},
|
||||
@@ -135,7 +135,7 @@ window.MAESTRO_I18N = {
|
||||
fgroups: { todo: 'Pendientes', doing: 'En curso', gate: 'Por revisar', bad: 'Incidencias', hold: 'En pausa', done: 'Hechas' },
|
||||
fStatus: 'Estado', fAssignee: 'Ejecutor', assignees: { agent: 'Agente', human: 'Humano' },
|
||||
fullRead: 'Pantalla completa', depsLabel: 'DEPS (todas deben completarse)', depDone: 'hecha', depWait: 'esperando',
|
||||
gateSection: 'Puertas de aprobación', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
|
||||
gateSection: 'Requiere acción', gateNeedsHuman: 'Manual', gRequeue: '↻ Reencolar', gTakeover: '⌨ Tomar', gCancelTask: '⊘ Cancelar', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
|
||||
rejectPlaceholder: 'Comentario de mejora (obligatorio)',
|
||||
taskSection: 'Árbol de tareas', newTask: '+ Nueva tarea', titleLabel: 'Título', titlePlaceholder: 'Qué hay que hacer',
|
||||
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
|
||||
@@ -149,7 +149,7 @@ window.MAESTRO_I18N = {
|
||||
gatePassed: ' aprobada',
|
||||
status: { init: 'Nueva', analyzing: 'Analizando', plan_review: 'Revisión de plan', decomposed: 'Descompuesta', speccing: 'Redactando spec', spec_review: 'Revisión de spec', ready: 'Lista', blocked: 'Bloqueada', queued: 'En cola', executing: 'Ejecutando', exec_review: 'Revisar y fusionar', failed: 'Fallida', needs_attention: 'Requiere atención', done: 'Hecha', paused: 'Pausada', cancelled: 'Cancelada' },
|
||||
gates: { plan: 'REVISIÓN DE PLAN', spec: 'REVISIÓN DE SPEC', exec: 'REVISIÓN DE RESULTADO' },
|
||||
gateSubtasks: 'Subtareas', noDeps: 'sin deps', dependsOn: 'deps',
|
||||
gateSubtasks: 'Subtareas', frozenPending: 'pendiente aprob.', scopeFiles: 'archivos', planTranscript: 'Traza de descomposición', noDeps: 'sin deps', dependsOn: 'deps',
|
||||
lblCodeReview: 'Revisión', lblSecurity: 'Seguridad', lblChanges: 'Cambios', vApprove: 'Aprobar', vReject: 'Rechazar', vNone: 'Sin veredicto',
|
||||
eventTypes: { 'task.created': 'Tarea creada', 'task.updated': 'Tarea actualizada', 'status.changed': 'Cambio de estado', 'approval.requested': 'Aprobación solicitada', 'approval.granted': 'Aprobación concedida', 'approval.rejected': 'Aprobación rechazada', 'run.started': 'Run iniciado', 'run.finished': 'Run terminado', 'project.synced': 'Todo sincronizado' },
|
||||
},
|
||||
@@ -185,7 +185,7 @@ window.MAESTRO_I18N = {
|
||||
fgroups: { todo: '未着手', doing: '進行中', gate: '承認待ち', bad: '異常', hold: '保留', done: '完了' },
|
||||
fStatus: 'ステータス', fAssignee: '担当', assignees: { agent: 'エージェント', human: '人手' },
|
||||
fullRead: '全画面で読む', depsLabel: 'DEPS · 依存(全完了で実行可)', depDone: '完了', depWait: '待機',
|
||||
gateSection: '承認ゲート', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
|
||||
gateSection: '要対応', gateNeedsHuman: '要人手', gRequeue: '↻ 再投入', gTakeover: '⌨ 引継ぎ', gCancelTask: '⊘ 取消', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
|
||||
rejectPlaceholder: '改善フィードバック(必須)',
|
||||
taskSection: 'タスクツリー', newTask: '+ 新規タスク', titleLabel: 'タイトル', titlePlaceholder: '何をしますか',
|
||||
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
|
||||
@@ -199,7 +199,7 @@ window.MAESTRO_I18N = {
|
||||
gatePassed: ' 承認',
|
||||
status: { init: '新規', analyzing: '分析・分解中', plan_review: '分解確認待ち', decomposed: '分解済み', speccing: '方針作成中', spec_review: '方針確認待ち', ready: '実行可能', blocked: '依存ブロック', queued: 'キュー待ち', executing: '実行中', exec_review: 'レビュー/マージ', failed: '失敗', needs_attention: '要対応', done: '完了', paused: '一時停止', cancelled: 'キャンセル' },
|
||||
gates: { plan: '分解レビュー', spec: '方針レビュー', exec: '結果レビュー' },
|
||||
gateSubtasks: '分解されたサブタスク', noDeps: '依存なし', dependsOn: '依存',
|
||||
gateSubtasks: '分解されたサブタスク', frozenPending: '承認待ち', scopeFiles: 'ファイル範囲', planTranscript: '分解トレース', noDeps: '依存なし', dependsOn: '依存',
|
||||
lblCodeReview: 'コードレビュー', lblSecurity: 'セキュリティ', lblChanges: '変更', vApprove: '承認', vReject: '却下', vNone: '判定なし',
|
||||
eventTypes: { 'task.created': 'タスク作成', 'task.updated': 'タスク更新', 'status.changed': 'ステータス変更', 'approval.requested': '承認リクエスト', 'approval.granted': '承認', 'approval.rejected': '却下', 'run.started': 'run 開始', 'run.finished': 'run 終了', 'project.synced': 'todo 同期' },
|
||||
},
|
||||
@@ -235,7 +235,7 @@ window.MAESTRO_I18N = {
|
||||
fgroups: { todo: 'À faire', doing: 'En cours', gate: 'À approuver', bad: 'Anomalies', hold: 'En attente', done: 'Terminées' },
|
||||
fStatus: 'Statut', fAssignee: 'Exécutant', assignees: { agent: 'Agent', human: 'Humain' },
|
||||
fullRead: 'Plein écran', depsLabel: 'DEPS (toutes requises)', depDone: 'terminée', depWait: 'en attente',
|
||||
gateSection: "Portes d'approbation", accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
|
||||
gateSection: 'À traiter', gateNeedsHuman: 'Manuel', gRequeue: '↻ Relancer', gTakeover: '⌨ Reprendre', gCancelTask: '⊘ Annuler', accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
|
||||
rejectPlaceholder: "Retour d'amélioration (obligatoire)",
|
||||
taskSection: 'Arbre des tâches', newTask: '+ Nouvelle tâche', titleLabel: 'Titre', titlePlaceholder: 'Que faut-il faire',
|
||||
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',
|
||||
@@ -249,7 +249,7 @@ window.MAESTRO_I18N = {
|
||||
gatePassed: ' approuvée',
|
||||
status: { init: 'Nouvelle', analyzing: 'Analyse', plan_review: 'Revue du plan', decomposed: 'Décomposée', speccing: 'Rédaction spec', spec_review: 'Revue de spec', ready: 'Prête', blocked: 'Bloquée', queued: 'En file', executing: 'En exécution', exec_review: 'Revue & fusion', failed: 'Échouée', needs_attention: 'À traiter', done: 'Terminée', paused: 'En pause', cancelled: 'Annulée' },
|
||||
gates: { plan: 'REVUE DU PLAN', spec: 'REVUE DE SPEC', exec: 'REVUE DU RÉSULTAT' },
|
||||
gateSubtasks: 'Sous-tâches', noDeps: 'sans deps', dependsOn: 'deps',
|
||||
gateSubtasks: 'Sous-tâches', frozenPending: 'en attente', scopeFiles: 'fichiers', planTranscript: 'Trace de décomposition', noDeps: 'sans deps', dependsOn: 'deps',
|
||||
lblCodeReview: 'Revue', lblSecurity: 'Sécurité', lblChanges: 'Modifs', vApprove: 'Approuver', vReject: 'Rejeter', vNone: 'Sans verdict',
|
||||
eventTypes: { 'task.created': 'Tâche créée', 'task.updated': 'Tâche mise à jour', 'status.changed': "Changement d'état", 'approval.requested': 'Approbation demandée', 'approval.granted': 'Approbation accordée', 'approval.rejected': 'Approbation rejetée', 'run.started': 'Run démarré', 'run.finished': 'Run terminé', 'project.synced': 'Todo synchronisé' },
|
||||
},
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<!-- Markdown 渲染(window.MaestroRichDoc 依赖)-->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<script src="data.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
@@ -37,6 +42,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="RichDoc.jsx"></script>
|
||||
<script type="text/babel" src="Transcript.jsx"></script>
|
||||
<script type="text/babel" src="Sidebar.jsx"></script>
|
||||
<script type="text/babel" src="EventPanel.jsx"></script>
|
||||
<script type="text/babel" src="Topbar.jsx"></script>
|
||||
|
||||
+7
-2
@@ -86,8 +86,13 @@ body{font-family:'IBM Plex Mono',monospace;background:var(--bg);color:var(--text
|
||||
|
||||
<!-- ── 知识库调研 ── -->
|
||||
<div class="cat">
|
||||
<div class="cat-hdr"><span class="ico">调研</span> 知识库调研 <span class="cnt">· 0</span></div>
|
||||
<div class="empty">(暂无)</div>
|
||||
<div class="cat-hdr"><span class="ico">调研</span> 知识库调研 <span class="cnt">· 1</span></div>
|
||||
<a class="doc" href="task-rules.html">
|
||||
<div class="doc-ttl">任务执行规则参考 · 当前实现</div>
|
||||
<div class="doc-path">docs/task-rules.html</div>
|
||||
<div class="doc-desc">后台 agent 处理任务的<strong>当前线上规则</strong>(非提案):复杂度路由(easy/medium/hard)+ 四类执行路径——① 任务拆解(HARD,只读 planner,2–6 子任务·depth≤4·5 字段·三段输出·落库·审批·子任务调度)· ② 方案撰写(MEDIUM)· ③ 任务执行(executor,worktree + verify/checks/diff/双复审硬闸 + auto-merge)· ④ 解冲突(conflict,真 git merge + P0 插队)。含<strong>跨类通用规则</strong>、<strong>模型档位</strong>(默认 opus-4-8 + 项目可配 + 回退链)、<strong>关键状态转移表</strong>。</div>
|
||||
<div class="tags"><span class="tag html">HTML</span><span class="tag design">规则参考</span></div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ── 排障 Runbook ── -->
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maestro · 任务执行规则参考</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#0d0d0d;--bg2:#141414;--bg3:#1a1a1a;--border:#252525;--border2:#333;--text:#e8e8e8;--text2:#a0a0a0;--text3:#555;--cyan:#00d4d4;--cyan2:#009999;--green:#4ec94e;--green2:#2d6e2d;--amber:#f0a500;--amber2:#9c6a00;--violet:#9b7bff;--violet2:#6644cc;--red:#ff5555;--red2:#aa2222;--blue:#5599ff;--blue2:#2255cc;--code-bg:#0a0a0a;--shadow:0 4px 32px rgba(0,0,0,.7)}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'IBM Plex Mono',monospace;background:var(--bg);color:var(--text);font-size:13px;line-height:1.7}
|
||||
.topbar{position:sticky;top:0;z-index:100;background:var(--bg2);border-bottom:1px solid var(--border);height:48px;display:flex;align-items:center;padding:0 24px;gap:12px}
|
||||
.topbar-brand{font-size:13px;font-weight:600;color:var(--cyan);letter-spacing:.06em}
|
||||
.topbar-sub{font-size:11px;color:var(--text3)}
|
||||
.topbar a{margin-left:auto;font-size:11px;color:var(--text3);text-decoration:none}
|
||||
.topbar a:hover{color:var(--cyan)}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:32px 24px 80px}
|
||||
.lead{font-size:13px;color:var(--text2);margin:0 0 10px;line-height:1.8}
|
||||
.lead strong{color:var(--text)}
|
||||
.meta{font-size:11px;color:var(--text3);margin:0 0 26px}
|
||||
h2{font-size:17px;color:var(--text);margin:36px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||
h2 .n{font-size:12px;color:var(--cyan);border:1px solid var(--cyan2);border-radius:4px;padding:1px 8px;font-weight:400}
|
||||
h3{font-size:14px;color:var(--cyan);margin:22px 0 8px}
|
||||
p{color:var(--text2);margin:8px 0}
|
||||
strong{color:var(--text)}
|
||||
em{color:var(--amber);font-style:normal}
|
||||
code{font-family:inherit;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:1px 5px;color:var(--cyan);font-size:12px}
|
||||
ul{margin:8px 0 8px 4px;padding-left:20px;color:var(--text2)}
|
||||
ol{margin:8px 0 8px 4px;padding-left:22px;color:var(--text2)}
|
||||
li{margin:4px 0}
|
||||
li::marker{color:var(--green)}
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--bg3);color:var(--text);font-weight:600}
|
||||
td{color:var(--text2)}
|
||||
td code,th code{font-size:11.5px}
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-left:3px solid var(--cyan2);border-radius:6px;padding:14px 16px;margin:12px 0}
|
||||
.card.violet{border-left-color:var(--violet2)}
|
||||
.card.green{border-left-color:var(--green2)}
|
||||
.card.amber{border-left-color:var(--amber2)}
|
||||
.card.red{border-left-color:var(--red2)}
|
||||
.card-ttl{font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.tag{font-size:10px;padding:1px 7px;border-radius:3px;border:1px solid}
|
||||
.tag.hard{color:var(--red);border-color:var(--red2);background:rgba(255,85,85,.08)}
|
||||
.tag.med{color:var(--amber);border-color:var(--amber2);background:rgba(240,165,0,.08)}
|
||||
.tag.easy{color:var(--green);border-color:var(--green2);background:rgba(78,201,78,.08)}
|
||||
.tag.ro{color:var(--blue);border-color:var(--blue2);background:rgba(85,153,255,.08)}
|
||||
.tag.wr{color:var(--violet);border-color:var(--violet2);background:rgba(155,123,255,.08)}
|
||||
.note{background:rgba(0,212,212,.06);border:1px solid var(--cyan2);border-radius:5px;padding:10px 13px;margin:12px 0;font-size:12px;color:var(--text2)}
|
||||
.note.warn{background:rgba(240,165,0,.06);border-color:var(--amber2)}
|
||||
.note strong{color:var(--text)}
|
||||
.foot{margin-top:40px;font-size:11px;color:var(--text3);border-top:1px solid var(--border);padding-top:14px}
|
||||
.src{color:var(--text3);font-size:11px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<span class="topbar-brand">MAESTRO</span>
|
||||
<span class="topbar-sub">· 任务执行规则参考</span>
|
||||
<a href="index.html">← 文档索引</a>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<p class="lead">Maestro 后台 agent 处理任务的<strong>当前实现规则</strong>(非设计提案,是线上真实行为)。按<strong>复杂度路由</strong>分四类执行路径:① 任务拆解(HARD)· ② 方案撰写(MEDIUM)· ③ 任务执行(executor)· ④ 解冲突(conflict),外加跨类通用规则与模型档位。</p>
|
||||
<p class="meta">真相源:<code>src/executor/runner.ts</code> · <code>src/daemon/orchestrator.ts</code> · <code>src/daemon/ingest.ts</code> · <code>src/executor/pipeline.ts</code> · <code>src/executor/models.ts</code> · <code>src/model/status.ts</code>。如与代码不符,以代码为准。</p>
|
||||
|
||||
<!-- ───────── 概览 ───────── -->
|
||||
<h2><span class="n">0</span> 复杂度 → 生命周期路由</h2>
|
||||
<p>任务按 <code>complexity</code>(可由 <code>auto</code> 让模型判定)走不同路径;只有产生代码改动的任务才过 <code>exec_review</code>(双复审 + 合并)。</p>
|
||||
<table>
|
||||
<thead><tr><th>复杂度</th><th>路径</th><th>人审闸</th><th>谁来做</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="tag easy">EASY</span></td><td><code>init → ready → queued → executing → exec_review → done</code></td><td>exec_review</td><td>executor(直接执行)</td></tr>
|
||||
<tr><td><span class="tag med">MEDIUM</span></td><td><code>init → speccing → spec_review → ready → … → exec_review → done</code></td><td>spec_review + exec_review</td><td>planner 写方案 → executor 执行</td></tr>
|
||||
<tr><td><span class="tag hard">HARD</span></td><td><code>init → analyzing → plan_review → decomposed →(子任务各自跑)→ done</code></td><td>plan_review +(各子任务的闸)</td><td>planner 拆解 → 子任务递归</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="note">三种 run 类型(<code>runs.kind</code>):<strong>planner</strong>(拆解 HARD / 写 MEDIUM 方案,只读)· <strong>executor</strong>(worktree 内改代码)· <strong>reviewer / security</strong>(执行后双复审,只读)· <strong>conflict</strong>(解合并冲突,本会话新增)。</div>
|
||||
|
||||
<!-- ───────── ① 拆解 ───────── -->
|
||||
<h2><span class="n">①</span> 任务拆解(decompose)<span class="tag hard">HARD only</span> <span class="tag ro">只读</span></h2>
|
||||
<div class="card">
|
||||
<div class="card-ttl">触发 + 执行环境</div>
|
||||
<ul>
|
||||
<li>仅 <strong>HARD</strong> 任务拆解;编排器把它领进 <code>analyzing</code> 态,起一个 <strong>planner run</strong>。</li>
|
||||
<li><strong>只读</strong>:cwd = 主仓(<em>不建 worktree、不改任何文件</em>);工具白名单仅 <code>Read / Glob / Grep</code> + <code>Bash(git log/diff/show)</code>。</li>
|
||||
<li>模型:planner 档,<strong>默认 <code>claude-opus-4-8</code></strong>(项目可配);<code>maxTurns=90</code>。</li>
|
||||
<li>触发前提:叶子任务 · 不在退避冷却 · 依赖全 <code>done</code> · 项目 <code>autonomy ≠ manual</code>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-ttl">拆解约束(prompt 硬性要求)</div>
|
||||
<ul>
|
||||
<li>拆成 <strong>2–6 个</strong>更小、可独立交付的子任务。</li>
|
||||
<li><strong>层级闸</strong>:<code>depth ≤ 4</code>(根=1),prompt 注入「剩余可拆层数」;子任务只有在<em>剩余层数 > 0</em> 时才允许再标 <code>hard</code>(防无限拆)。</li>
|
||||
<li>每个子任务 5 个字段:<code>title</code> · <code>complexity</code>(easy/medium/hard) · <code>priority</code>(0/1/2) · <code>deps</code>(依赖<em>本列表的 0-based 序号</em>) · <code>files</code>(文件范围 glob)。</li>
|
||||
<li><code>files</code> = 该子任务预计改动的文件范围;执行时改<strong>越界文件会被硬闸拦截</strong>,故须尽量精确,不确定留空数组(不限)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-ttl">输出格式(三段)</div>
|
||||
<ol>
|
||||
<li><strong>Markdown 表格总览</strong>(序号 / 标题 / 复杂度 / 优先级 / 依赖 / 文件范围)——给人审看;</li>
|
||||
<li>一段<strong>分析与拆解理由</strong>正文;</li>
|
||||
<li>末尾<strong>唯一一个 fenced JSON</strong> <code>{plan, subtasks:[…]}</code>——给机器解析。解析失败 = planner 失败、退避重试。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="card amber">
|
||||
<div class="card-ttl">落库(ingest 处理结果)</div>
|
||||
<ul>
|
||||
<li>JSON 的 <code>plan</code>(一句话理由)→ 存父任务 <code>plan</code> 字段(即评审界面的「说明」);</li>
|
||||
<li>每个 subtask 建成子任务:<code>priority</code>、<code>files→scopeFiles</code> 直接落库;<code>deps</code> 先全建好子任务再做<strong>「序号→taskId」二次映射</strong>写依赖;</li>
|
||||
<li><strong>⚠ 表格 + 分析正文那两段被丢弃</strong>,只留在该 run 的 <em>transcript</em> 里(评审界面「拆解轨迹」可展开查看)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card violet">
|
||||
<div class="card-ttl">审批 + 之后调度</div>
|
||||
<ul>
|
||||
<li>默认 → 转 <code>plan_review</code> <strong>人审</strong>。</li>
|
||||
<li><strong>可选自动放行</strong>(默认关):项目 <code>auto-approved</code> + <code>autoApprovePlan</code> 开 + 拆解结果<strong>全 easy</strong> + <strong>子任务数 ≤ 5</strong>,四条全满足才跳过人审直接 <code>decomposed</code>。</li>
|
||||
<li>父任务在 <code>plan_review</code> / <code>analyzing</code> 期间,<strong>子任务被冻结</strong>(不可领取,UI 显示「待拆解确认」)。</li>
|
||||
<li>批准后(plan_review→decomposed),子任务解冻,按 <strong>deps + priority</strong> 调度,<strong>各自独立 worktree</strong>(都从 main 拉出)执行、独立合并回 main。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<div class="card-ttl">失败 + 收口</div>
|
||||
<ul>
|
||||
<li>解析失败 / run 失败 → <code>failPlanAttempt</code> 退避重试,超 <code>maxRetries</code>(默认 2)→ 转 <code>needs_attention</code> 待人工;</li>
|
||||
<li>父任务 <code>decomposed</code> 后,<strong>子任务全 done → 父任务自动 done</strong>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ───────── ② 方案 ───────── -->
|
||||
<h2><span class="n">②</span> 方案撰写(spec)<span class="tag med">MEDIUM</span> <span class="tag ro">只读</span></h2>
|
||||
<ul>
|
||||
<li>MEDIUM 任务进 <code>speccing</code> 态,起 <strong>planner run</strong>(与拆解同为 planner kind,但只读写「方案」不拆子任务)。</li>
|
||||
<li>只读跑在主仓;工具同拆解(Read/Glob/Grep + git 只读);<code>maxTurns=60</code>。</li>
|
||||
<li>输出 = 一份 Markdown 方案正文(改动什么 / 为什么 / 验收),存任务 <code>spec</code> 字段。</li>
|
||||
<li>→ 转 <code>spec_review</code> <strong>人审</strong>;批准后 → <code>ready</code> 进执行队列。reject 须带意见,回流 <code>speccing</code> 重写。</li>
|
||||
</ul>
|
||||
|
||||
<!-- ───────── ③ 执行 ───────── -->
|
||||
<h2><span class="n">③</span> 任务执行(executor)<span class="tag wr">改代码</span></h2>
|
||||
<div class="card">
|
||||
<div class="card-ttl">执行环境</div>
|
||||
<ul>
|
||||
<li>每个任务独立 <strong>worktree</strong>(分支 <code>maestro/<taskId></code>,从 <code>defaultBranch</code>(main) 拉出);<code>permissionMode=acceptEdits</code>。</li>
|
||||
<li>工具:<code>Read/Edit/Write/Glob/Grep</code> + <code>git</code>(status/diff/log/add/commit,<em>无 push</em>) + 测试/构建(npm/go/pytest/make…,仍关在 worktree、不发包);<code>maxTurns=100</code>。</li>
|
||||
<li>模型:executor 档(默认 opus-4-8,项目可配;旧 <code>project.model</code> 也覆盖 executor)。</li>
|
||||
<li><strong>必须 commit</strong>,message 带任务 ID(<code>ensureCommitted</code> 兜底)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card amber">
|
||||
<div class="card-ttl">approve 前的硬闸(任一不过 = 不进 exec_review)</div>
|
||||
<ol>
|
||||
<li><strong>执行前同步 main + 分歧重评估(规则13)</strong>:起跑前把最新 <code>origin/main</code> 并进 worktree;若撞上<em>本任务声明范围内</em>的文件改动 → 转 <code>needs_attention</code> 待重评估(不盲目硬解)。</li>
|
||||
<li><strong>verify 闸</strong>:项目 <code>verifyCmd</code> 在 worktree 内跑(10min,exit 0 才过;未配=视为通过)。</li>
|
||||
<li><strong>分项检查闸 checks</strong>:独立的 lint / typecheck / build(项目可配,分项报告哪项挂)。</li>
|
||||
<li><strong>diff 闸</strong>:体量超阈值、或改了<em>声明范围外文件</em>(task.scopeFiles 非空时)→ 拦截。</li>
|
||||
<li><strong>双复审硬闸</strong>:code review + security audit <em>并行</em>跑(各只读、最强档不被 project.model 降档);<strong>任一 verdict=reject → 退回重执行</strong>(带复审意见),不进 exec_review。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="card violet">
|
||||
<div class="card-ttl">审批 + 合并</div>
|
||||
<ul>
|
||||
<li>全绿 → 转 <code>exec_review</code> <strong>人审</strong>;accept 触发 <code>--no-ff</code> 自动合并回 main(<code>merge:false</code> 可跳过合并)。</li>
|
||||
<li><strong>可选自动放行</strong>(默认关):<code>auto-approved</code> + <code>autoApproveExec</code> 开 + 双复审均 approve → 跳过人审自动合并。</li>
|
||||
<li>合并冲突 → 自动建 <strong>P0 解冲突补救任务</strong>(见 ④),原任务留 exec_review,补救成功后自动收口。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ───────── ④ 解冲突 ───────── -->
|
||||
<h2><span class="n">④</span> 解冲突(conflict)<span class="tag wr">改代码</span></h2>
|
||||
<ul>
|
||||
<li>exec_review 合并撞冲突时,自动建一个 <strong>P0 最高优先级</strong>补救任务,以专用 <code>runKind=conflict</code> 跑。</li>
|
||||
<li>专用管线:worker 在补救 worktree 内<strong>真正发起 <code>git merge <原分支></code></strong> 触发冲突态,再交 CC 逐个解(白名单额外含 <code>Bash(git merge:*)</code>)。</li>
|
||||
<li>专用 prompt:保留两边意图、解完跑测试验证、禁丢任一方改动、commit 沿用合并信息。</li>
|
||||
<li>模型:conflict 角色,项目可配,<strong>默认 opus-4-8</strong>。</li>
|
||||
<li>走 exec_review + 双复审;合并成功后<strong>自动收口</strong>原任务。P0 插队但<em>不中断在途任务</em>(No-preemption)。</li>
|
||||
</ul>
|
||||
|
||||
<!-- ───────── 通用规则 ───────── -->
|
||||
<h2><span class="n">⑤</span> 跨类通用规则</h2>
|
||||
<table>
|
||||
<thead><tr><th>规则</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>执行期锁</strong></td><td>任务一旦有在途 run(planner/executor/reviewer/conflict)→ 锁定只读:禁改 title/spec/complexity/deps、禁再调度;run 结束(finish/fail/reap)解锁。</td></tr>
|
||||
<tr><td><strong>worktree 隔离</strong></td><td>写类 run 只在自己 worktree 内改动,禁碰 worktree 外文件;planner/reviewer 只读、不建 worktree。</td></tr>
|
||||
<tr><td><strong>不 push / 不部署</strong></td><td>任何 run 都不得 <code>git push</code> / 对外发布;解冲突的 merge 是受控本地合并。</td></tr>
|
||||
<tr><td><strong>commit 含 task ID</strong></td><td>所有产出 commit message 必带任务 ID,<code>ensureCommitted</code> 兜底。</td></tr>
|
||||
<tr><td><strong>重试退避 + 升级</strong></td><td>失败按退避重试,超 <code>maxRetries</code>(默认 2)→ <code>needs_attention</code> 转人工(planner 留原态、executor 走 failed)。</td></tr>
|
||||
<tr><td><strong>自动放行默认关</strong></td><td>所有「跳过人审」(plan/exec)默认关闭,须项目级开关 + <code>auto-approved</code> 才生效。</td></tr>
|
||||
<tr><td><strong>reject 必带意见</strong></td><td>plan/spec/exec 的驳回必须带 reason,按状态机回流重做。</td></tr>
|
||||
<tr><td><strong>复审独立性</strong></td><td>复审只读、与执行分离、用最强模型、不被 <code>project.model</code> 降档;verdict=reject 是硬闸。</td></tr>
|
||||
<tr><td><strong>daemon 唯一 DB 写者</strong></td><td>worker 不碰 DB,只读 job.json、写 outbox.ndjson;状态变更只经 <code>transition()</code> 守卫。</td></tr>
|
||||
<tr><td><strong>超时分层</strong></td><td>planner/executor 30min(可配)· verify 10min · 复审 15min(并行)· conflict 给足强档时间。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ───────── 模型 ───────── -->
|
||||
<h2><span class="n">⑥</span> 模型档位</h2>
|
||||
<p>内置默认:<strong>每个角色 × 每个复杂度都是 <code>claude-opus-4-8</code></strong>(<code>DEFAULT_MODEL</code>)。可<strong>按项目</strong>经 <code>projects.models</code>(JSON) 配置。</p>
|
||||
<p><strong>解析优先级</strong>(高→低):① <code>project.models[role]</code>(per-role,含 reviewer/conflict)→ ② 旧 <code>project.model</code>(仅 executor/planner)→ ③ env(<code>MAESTRO_MODEL_*</code> / <code>_REVIEW_*</code> / <code>_PLAN_*</code>)→ ④ <code>DEFAULT_MODEL</code>。</p>
|
||||
<p>四角色:<code>executor</code> / <code>planner</code> / <code>reviewer</code> / <code>conflict</code>。不可用回退链:<code>opus-4-8 → sonnet-4-6 → fable-5</code>(单 run 内只重试一次)。</p>
|
||||
<div class="note warn"><strong>注</strong>:<code>claude-fable-5</code> 在部分环境不可用,已移出内置默认;原「按复杂度分档(hard=fable / medium=opus / easy=sonnet)」的设计因此改为<strong>统一 opus-4-8 + 项目可配</strong>。</div>
|
||||
|
||||
<!-- ───────── 状态机 ───────── -->
|
||||
<h2><span class="n">⑦</span> 关键状态转移</h2>
|
||||
<table>
|
||||
<thead><tr><th>从</th><th>到</th><th>触发</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>init</code></td><td><code>analyzing</code> / <code>speccing</code> / <code>ready</code></td><td>按 hard / medium / easy 路由</td></tr>
|
||||
<tr><td><code>analyzing</code></td><td><code>plan_review</code></td><td>拆解完成</td></tr>
|
||||
<tr><td><code>plan_review</code></td><td><code>decomposed</code> / <code>analyzing</code></td><td>accept / reject(+意见)</td></tr>
|
||||
<tr><td><code>speccing</code></td><td><code>spec_review</code></td><td>方案完成</td></tr>
|
||||
<tr><td><code>spec_review</code></td><td><code>ready</code> / <code>speccing</code></td><td>accept / reject(+意见)</td></tr>
|
||||
<tr><td><code>ready</code></td><td><code>queued</code> / <code>blocked</code></td><td>领取 / 依赖未满足</td></tr>
|
||||
<tr><td><code>queued</code></td><td><code>executing</code></td><td>worker 起跑</td></tr>
|
||||
<tr><td><code>executing</code></td><td><code>exec_review</code> / <code>failed</code></td><td>执行完成 / 失败</td></tr>
|
||||
<tr><td><code>exec_review</code></td><td><code>done</code> / <code>ready</code></td><td>accept(合并) / reject(返工)</td></tr>
|
||||
<tr><td><code>failed</code></td><td><code>queued</code> / <code>needs_attention</code></td><td>重试 / 超阈值</td></tr>
|
||||
<tr><td><code>decomposed</code></td><td><code>done</code></td><td>子任务全 done</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="src">完整合法转移表见 <code>src/model/status.ts</code> 的 <code>TRANSITIONS</code>;状态机/ER 全图见 <a href="optimization-plan.html" style="color:var(--cyan)">优化方案文档</a> ⑤⑥ 节。</p>
|
||||
|
||||
<div class="foot">单一入口见 <a href="index.html" style="color:var(--cyan)">docs/index.html</a> · 本文档描述当前线上规则,随实现演进更新 · 与代码不符以代码为准。</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+71
-3
@@ -25,6 +25,70 @@ function dataDir(): string {
|
||||
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
||||
}
|
||||
|
||||
/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/bmp': 'bmp',
|
||||
'image/x-icon': 'ico',
|
||||
'image/vnd.microsoft.icon': 'ico',
|
||||
'image/tiff': 'tiff',
|
||||
'image/heic': 'heic',
|
||||
'image/heif': 'heif',
|
||||
'image/avif': 'avif',
|
||||
'application/pdf': 'pdf',
|
||||
'application/zip': 'zip',
|
||||
'application/json': 'json',
|
||||
'application/xml': 'xml',
|
||||
'text/plain': 'txt',
|
||||
'text/markdown': 'md',
|
||||
'text/csv': 'csv',
|
||||
'text/html': 'html',
|
||||
};
|
||||
|
||||
/** MIME 类型 → 扩展名(无点)。未知映射时对 image/<subtype> 兜底取 subtype;其余返回 ''。 */
|
||||
export function extFromMime(mimetype: string | undefined): string {
|
||||
if (!mimetype) return '';
|
||||
const key = mimetype.split(';')[0].trim().toLowerCase();
|
||||
if (MIME_EXT[key]) return MIME_EXT[key];
|
||||
const m = key.match(/^image\/([a-z0-9.+-]+)$/);
|
||||
if (m) return m[1].replace(/[^a-z0-9]/g, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
/** basename 末尾是否带「看起来像扩展名」的后缀(1-8 位字母数字)。 */
|
||||
function hasExtension(name: string): boolean {
|
||||
return /\.[A-Za-z0-9]{1,8}$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算附件落盘安全文件名。规则:
|
||||
* 1. 仅取 basename,净化非法字符(`[^\w.\-]` → `_`),剥掉前导点(防隐藏/空名)。
|
||||
* 2. 主名为空或为占位名(blob/image/file/unknown/untitled/paste)→ 视为「无名粘贴 blob」,
|
||||
* 生成唯一名 `paste-<ts>-<seq>`,扩展名优先取 mimetype 推断、其次保留原扩展名。
|
||||
* 3. 有正常主名但缺扩展名 → 按 mimetype 补扩展名(mimetype 未知则保持原样)。
|
||||
* 返回值保证非空。
|
||||
*/
|
||||
export function safeAttachmentName(rawName: string | undefined, mimetype: string | undefined, seq = 0): string {
|
||||
const ext = extFromMime(mimetype);
|
||||
const sanitized = basename((rawName ?? '').trim()).replace(/[^\w.\-]/g, '_').replace(/^\.+/, '');
|
||||
const withExt = hasExtension(sanitized);
|
||||
const stem = withExt ? sanitized.replace(/\.[A-Za-z0-9]{1,8}$/, '') : sanitized;
|
||||
const isPlaceholder = stem === '' || /^(blob|image|file|unknown|untitled|paste)$/i.test(stem);
|
||||
if (isPlaceholder) {
|
||||
const origExt = withExt ? sanitized.slice(sanitized.lastIndexOf('.') + 1) : '';
|
||||
const useExt = ext || origExt;
|
||||
const base = `paste-${Date.now()}-${seq}`;
|
||||
return useExt ? `${base}.${useExt}` : base;
|
||||
}
|
||||
if (!withExt && ext) return `${sanitized}.${ext}`;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||||
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
|
||||
return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) };
|
||||
@@ -137,6 +201,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||||
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||||
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
|
||||
if (b.globalConcurrency !== undefined)
|
||||
patch.globalConcurrency = b.globalConcurrency === null || b.globalConcurrency === '' ? 0 : Number(b.globalConcurrency);
|
||||
return store.putSettings(patch);
|
||||
});
|
||||
|
||||
@@ -278,11 +344,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
const dir = join(dataDir(), 'tasks', id, 'attachments');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const saved: Array<{ name: string; type: string; path: string }> = [];
|
||||
let seq = 0;
|
||||
for await (const part of req.files()) {
|
||||
const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`;
|
||||
// 无名/无扩展名(如剪贴板粘贴 blob)→ 按 mimetype 生成安全文件名+扩展名
|
||||
const safe = safeAttachmentName(part.filename, part.mimetype, seq++);
|
||||
await pipeline(part.file, createWriteStream(join(dir, safe)));
|
||||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` });
|
||||
saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename || safe} 超过 25MB 上限` });
|
||||
saved.push({ name: part.filename || safe, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||||
}
|
||||
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
||||
return { attachments: store.addAttachments(id, saved) };
|
||||
|
||||
+13
-1
@@ -216,7 +216,19 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
|
||||
transcriptRef: rec.executor.transcriptRef ?? undefined,
|
||||
claudeSessionId: rec.executor.sessionId ?? undefined,
|
||||
});
|
||||
// 退回重执行(带复审意见)
|
||||
// 范围型驳回检测:任务有非空 scopeFiles 且复审拒因「需求超出声明范围/越界/范围外」——重试 executor 必然无解
|
||||
// (它碰不了范围外文件,只会同样被驳回),故停止重试、直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重拆。
|
||||
const task = store.getTask(taskId);
|
||||
const scoped = !!(task?.scopeFiles && task.scopeFiles.length);
|
||||
const fullReview = `${rec.code.summary || ''}\n${rec.security.summary || ''}`;
|
||||
const scopeReject = scoped
|
||||
&& /out of scope|越界|范围外|声明范围|文件闸|超出.{0,6}范围|scope[^\n]{0,40}(undone|outside|out of)/i.test(fullReview);
|
||||
if (scopeReject) {
|
||||
store.failScopeReject(taskId, `复审因范围不足驳回(停止重试,待放宽 scopeFiles 或重新拆解):${reason}`);
|
||||
log.info(`task=${taskId} run=${runId} 范围型驳回 → needs_attention(停止重试)`);
|
||||
return;
|
||||
}
|
||||
// 普通复审驳回 → 退回重执行(带复审意见)
|
||||
store.failTaskAttempt(taskId, null, `自动复审拒绝,退回重执行:${reason}`);
|
||||
log.info(`task=${taskId} run=${runId} 复审拒绝 → 退回重执行`);
|
||||
return;
|
||||
|
||||
@@ -209,9 +209,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
||||
/**
|
||||
* 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务,
|
||||
* executor + planner 通用)。在 active<concurrency 时按 claimable 领新任务并 spawn worker。
|
||||
*
|
||||
* 全局并发闸(settings.globalConcurrency,0/缺省=不限)与 per-project 闸【串联】:两闸都过才领。
|
||||
* 全局在途总数(globalActive)轮初查一次、轮内手动 ++(claimOne 同步起新 started run),命中即 return——
|
||||
* 达上限后任何项目都不应再领,故 return(跨所有项目停止)而非 break(只停当前项目)。
|
||||
*/
|
||||
function claimTick(): void {
|
||||
try {
|
||||
const cap = store.getSettings().globalConcurrency; // 0/缺省 = 不限
|
||||
let globalActive = store.globalInflightCount(); // 轮初的全局在途总数
|
||||
if (cap > 0 && globalActive >= cap) return; // 全局闸已满 → 整轮跨项目都不领
|
||||
for (const p of store.listProjects()) {
|
||||
if (p.status !== 'active' || p.autonomy === 'manual') continue;
|
||||
const inflight = store.inflightTaskIds(p.id);
|
||||
@@ -219,9 +226,11 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
||||
if (active >= p.concurrency) continue;
|
||||
|
||||
for (const { task, score } of claimable(p, inflight)) {
|
||||
if (active >= p.concurrency) break;
|
||||
if (active >= p.concurrency) break; // per-project 闸
|
||||
if (cap > 0 && globalActive >= cap) return; // 全局闸:达上限即跨所有项目停止领取
|
||||
claimOne(p, task, score);
|
||||
active++;
|
||||
globalActive++; // 本轮内手动累加(claimOne 已起新 started run)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -208,7 +208,13 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
|
||||
'- complexity:easy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
|
||||
'- priority:0=P0最紧急 / 1=P1中 / 2=P2最低',
|
||||
'- deps:依赖本列表中其他子任务的 0-based 序号(无依赖填 [])',
|
||||
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。尽量精确——执行时改动越界文件会被硬闸拦截。无法确定就留空数组(不限范围)。',
|
||||
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。执行时改动越界文件会被硬闸拦截,所以这个范围是给 executor 的【硬约束】,不是建议。',
|
||||
'',
|
||||
'### files 范围铁律(设窄会让任务做不完、卡死成 needs_attention,务必遵守)',
|
||||
'- **必须覆盖该子任务全部跨层足迹**:把它真正要碰的【所有层】都列进去——前端(design/、app/)、后端(src/)、接线、测试。漏掉任一层,executor 就碰不了那层、只能交半成品被驳回。',
|
||||
'- **隐含后端的能力别塞进纯前端范围**:删除 / 去重 / 持久化 / 列表查询 等通常需要后端端点(src/api、src/store)。若一个子任务含这类能力,files 必须同时包含后端路径,否则就【拆成两个子任务】(如「后端 CRUD 端点」+「前端 UI」,用 deps 串联)。',
|
||||
'- **宁可留空,不可设窄**:拿不准完整范围时,**留空数组**(= 不限、不触发越界闸)远好于猜一个偏窄的范围——窄而错会直接卡死任务,空只是放权。',
|
||||
'- 一句话:要么把范围列【全】,要么【留空】,绝不要列【一半】。',
|
||||
'',
|
||||
'## 输出格式(重要,必须严格遵守)',
|
||||
'**第一步**:先输出一个 Markdown 表格总览:',
|
||||
|
||||
@@ -30,11 +30,14 @@ export interface UserSettings {
|
||||
budgetUsd: number | null;
|
||||
budgetPeriod: 'day' | 'month';
|
||||
model: string | null;
|
||||
/** 跨所有项目的在途 run 总数上限(全局并发闸);0 = 不限。与 concurrency(新建项目默认值)相互独立。 */
|
||||
globalConcurrency: number;
|
||||
}
|
||||
const SETTINGS_DEFAULT: UserSettings = {
|
||||
autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000,
|
||||
autoApprovePlan: false, autoApproveExec: false,
|
||||
budgetUsd: null, budgetPeriod: 'month', model: null,
|
||||
globalConcurrency: 0,
|
||||
};
|
||||
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
||||
|
||||
@@ -834,6 +837,24 @@ export class Store {
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 范围型驳回:复审因「任务需求超出其声明范围(scopeFiles)」而拒——重试 executor 必然无解(它碰不了范围外文件,
|
||||
* 只会同样被驳回,盲目重试到 needs_attention 纯属浪费)。故不进重试链:补记一条 failed executor run 承载原因
|
||||
* (供 UI lastRunError 兜底显示),直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重新拆解。
|
||||
*/
|
||||
failScopeReject(taskId: string, reason: string): Task {
|
||||
const row = this.getTaskRow(taskId);
|
||||
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
const r = this.startRun(taskId, 'executor');
|
||||
this.finishRun(r.id, 'failed', { error: reason });
|
||||
if ((this.getTaskRow(taskId)!.status as TaskStatus) !== 'failed') {
|
||||
this.transition(taskId, 'failed', { by: 'failScopeReject', error: reason });
|
||||
}
|
||||
this.setNextEligibleAt(taskId, null);
|
||||
this.transition(taskId, 'needs_attention', { by: 'failScopeReject', reason });
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用规则13 执行前分歧重评估:sync main 发现 defaultBranch 改动与任务声明范围(scopeFiles)重叠,
|
||||
* 原方案可能过时 → 不重试、不计失败次数,直接把任务转 needs_attention 待人工重评估。
|
||||
@@ -918,6 +939,11 @@ export class Store {
|
||||
return new Set(rows.map((r) => r.tid));
|
||||
}
|
||||
|
||||
/** 跨所有项目的在途 run 总数(全局并发闸用;与 inflightTaskIds 同口径=started run,覆盖 executor + planner)。 */
|
||||
globalInflightCount(): number {
|
||||
return (this.db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE status = 'started'`).get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/** 所有 started run + 其任务(reap / ingest / reconcile 通用,覆盖 executor + planner + 残留复审 run)。 */
|
||||
liveRunsWithTask(): Array<{ task: Task; run: Run }> {
|
||||
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started' ORDER BY started_at`).all() as RunRow[];
|
||||
|
||||
@@ -443,6 +443,69 @@ test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
// ───────────────────────── 全局并发闸(跨项目在途总数上限)─────────────────────────
|
||||
|
||||
/** 单 store 建两个 active / auto-easy 项目,各塞 n 个 easy 任务,per-project 并发足够大(不成为瓶颈)。 */
|
||||
function setupTwoProjects(n = 2, concurrency = 5): { store: Store; p1: string; p2: string } {
|
||||
const store = new Store(':memory:');
|
||||
const p1 = store.createProject({ name: 'g1', repoPath: '/tmp/g1-' + Math.random(), autonomy: 'auto-easy', concurrency });
|
||||
const p2 = store.createProject({ name: 'g2', repoPath: '/tmp/g2-' + Math.random(), autonomy: 'auto-easy', concurrency });
|
||||
for (let i = 0; i < n; i++) {
|
||||
store.createTask({ projectId: p1.id, title: `p1-${i}`, complexity: 'easy' });
|
||||
store.createTask({ projectId: p2.id, title: `p2-${i}`, complexity: 'easy' });
|
||||
}
|
||||
return { store, p1: p1.id, p2: p2.id };
|
||||
}
|
||||
|
||||
test('全局闸:globalConcurrency=1 达上限 → 跨所有项目只领一个(per-project 闸都没满也阻止)', () => {
|
||||
const { store } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 1 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 1, '全局闸=1:两项目共 4 个可领任务,本轮只放一个');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:globalConcurrency=0(缺省)不限 → 两项目任务全领(不误伤)', () => {
|
||||
const { store, p1, p2 } = setupTwoProjects(2, 5);
|
||||
// 不设 globalConcurrency(默认 0)
|
||||
assert.equal(store.getSettings().globalConcurrency, 0);
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 4, '不限:两项目各 2 个共 4 个全部领取');
|
||||
assert.equal(store.listTasks(p1).filter((t) => t.status === 'executing').length, 2);
|
||||
assert.equal(store.listTasks(p2).filter((t) => t.status === 'executing').length, 2);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:globalConcurrency=3 两项目共 4 任务 → 一轮恰好领 3(边界)', () => {
|
||||
const { store } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 3 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 3, '全局闸=3:达上限即跨项目停止,恰好领 3 个');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:在途已达上限 → 整轮直接不领(轮初短路)', () => {
|
||||
const { store, p1 } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 1 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
const orch = createOrchestrator(store, noopLog, deps);
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1);
|
||||
assert.equal(store.globalInflightCount(), 1, '已有 1 个在途 started run');
|
||||
// 再来一轮:全局在途已=1=cap → 不再领
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1, '全局在途已达上限 → 整轮不领');
|
||||
assert.ok(store.listTasks(p1).length >= 0);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('并发闸用 inflight:1 个 executing + 1 个 planner 占满 concurrency=2', () => {
|
||||
const { store, projectId } = setup('auto-approved', 2);
|
||||
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
|
||||
|
||||
@@ -163,6 +163,25 @@ test('markReeval(规则13):executing → needs_attention,收尾 run 为
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('failScopeReject(范围型驳回):直接 needs_attention、不进重试链、清退避,记一条 failed run 承载原因', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'scoperej', repoPath: '/tmp/scoperej-' + Math.random() });
|
||||
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['design/**'] });
|
||||
s.setOperations(t.id, 'op');
|
||||
s.transition(t.id, 'queued');
|
||||
s.transition(t.id, 'executing');
|
||||
|
||||
const after = s.failScopeReject(t.id, '复审因范围不足驳回(停止重试):Scope A entirely undone');
|
||||
assert.equal(after.status, 'needs_attention', '范围型驳回应直接转 needs_attention(不重试)');
|
||||
assert.equal(after.nextEligibleAt, null, '应清退避、不进重试链');
|
||||
|
||||
// 补记一条 failed executor run 承载原因(供 UI lastRunError 兜底)
|
||||
const failed = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
|
||||
assert.equal(failed.length, 1, '应补记一条 failed executor run');
|
||||
assert.match(failed[0].error ?? '', /范围不足/);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed)', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() });
|
||||
|
||||
+30
@@ -132,6 +132,10 @@ const I18N = {
|
||||
toastNameRequired: '名称与仓库路径必填',
|
||||
toastTaskNotFound: '任务不在当前项目',
|
||||
toastConcInt: '最大并发必须是 ≥1 的整数',
|
||||
globalSettings: '全局设置', globalConc: '全局并发上限',
|
||||
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
|
||||
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
|
||||
toastSettingsSaved: '全局设置已保存',
|
||||
toastRejectRequired: '驳回意见不能为空',
|
||||
reorderSaved: '项目顺序已保存',
|
||||
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
|
||||
@@ -274,6 +278,10 @@ const I18N = {
|
||||
toastNameRequired: 'Name and repo path are required',
|
||||
toastTaskNotFound: 'Task not found in current project',
|
||||
toastConcInt: 'Max concurrency must be an integer ≥1',
|
||||
globalSettings: 'Settings', globalConc: 'Global concurrency cap',
|
||||
globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.',
|
||||
globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)',
|
||||
toastSettingsSaved: 'Settings saved',
|
||||
toastRejectRequired: 'Rejection feedback is required',
|
||||
reorderSaved: 'Project order saved',
|
||||
cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.',
|
||||
@@ -1752,6 +1760,17 @@ document.addEventListener('click', (ev) => {
|
||||
$('#modalRoot').hidden = true;
|
||||
break;
|
||||
|
||||
case 'open-settings':
|
||||
api('/api/settings').then((s) => {
|
||||
$('#setGlobalConc').value = s.globalConcurrency ?? 0;
|
||||
$('#settingsRoot').hidden = false;
|
||||
$('#setGlobalConc').focus();
|
||||
}).catch((e) => toast(e.message));
|
||||
break;
|
||||
case 'close-settings':
|
||||
$('#settingsRoot').hidden = true;
|
||||
break;
|
||||
|
||||
case 'toggle-new-task': {
|
||||
const p = $('#newTaskPanel');
|
||||
p.hidden = !p.hidden;
|
||||
@@ -2234,6 +2253,16 @@ $('#newProjectForm').addEventListener('submit', (ev) => {
|
||||
}, t('toastProjectCreated', { name }));
|
||||
});
|
||||
|
||||
$('#settingsForm').addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const n = Number($('#setGlobalConc').value);
|
||||
if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; }
|
||||
act(async () => {
|
||||
await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) });
|
||||
$('#settingsRoot').hidden = true;
|
||||
}, t('toastSettingsSaved'));
|
||||
});
|
||||
|
||||
$('#newTaskPanel').addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const f = ev.target;
|
||||
@@ -2268,6 +2297,7 @@ document.addEventListener('keydown', (ev) => {
|
||||
const am = $('#archiveModalRoot');
|
||||
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
|
||||
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
|
||||
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; }
|
||||
$('#modalRoot').hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<div class="side-foot">
|
||||
<span id="wsDot" class="ws-dot off"></span>
|
||||
<span id="wsText" data-i18n="connecting">连接中…</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn btn-ghost btn-xs" data-action="open-settings" data-i18n-title="globalSettings" title="全局设置"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="var(--icon-accent,#5fdd7d)"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg> <span data-i18n="globalSettings">全局设置</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -261,6 +263,22 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 全局设置 模态 -->
|
||||
<div id="settingsRoot" class="modal-root" hidden>
|
||||
<div class="modal-mask" data-action="close-settings"></div>
|
||||
<form id="settingsForm" class="modal panel">
|
||||
<div class="sec-head"><span class="head-mark">▍</span><span data-i18n="globalSettings">全局设置</span></div>
|
||||
<label><span data-i18n="globalConc">全局并发上限</span>
|
||||
<input id="setGlobalConc" type="number" min="0" step="1" value="0" autocomplete="off">
|
||||
</label>
|
||||
<p class="cfg-current" data-i18n="globalConcHint">跨所有项目的在途任务总数上限;0 = 不限。</p>
|
||||
<div class="form-row form-actions">
|
||||
<button type="submit" class="btn btn-solid" data-action="save-settings" data-i18n="save">保存</button>
|
||||
<button type="button" class="btn" data-action="close-settings" data-i18n="cancel">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="previewRoot" class="preview-root" hidden></div>
|
||||
<div id="archiveModalRoot" class="preview-root" hidden></div>
|
||||
<div id="toastRoot" class="toast-root"></div>
|
||||
|
||||
Reference in New Issue
Block a user