merge: maestro/tsk_BJLguXuWkyq4 [tsk_BJLguXuWkyq4]

This commit is contained in:
maestro
2026-06-30 09:13:27 +08:00
5 changed files with 107 additions and 1 deletions
+6 -1
View File
@@ -183,10 +183,15 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
const lastRunError = store.lastRunErrorOf(task.id);
// L3 拆解背景:executor 子任务带父任务意图 + 兄弟状态(planner 跑在父任务上,不需要)
const context = isPlanner ? null : store.taskContextOf(task.id);
// 人工驳回意见:返工角色 → 对应闸。executor 返工读 exec 闸;
// planner-spec 读 spec 闸;planner-decompose 读 plan 闸。
const rejectGate = runKind === 'planner-spec' ? 'spec'
: runKind === 'planner-decompose' ? 'plan' : 'exec';
const lastRejectReason = store.lastRejectReasonOf(task.id, rejectGate);
d.writeJobSpec({
runId: run.id,
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context },
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context, lastRejectReason },
project, worktreeDir: dir, branch, runKind,
});
+21
View File
@@ -77,6 +77,23 @@ function lastErrorLines(task: Task): string[] {
];
}
/**
* 人工驳回意见注入:返工任务带「上次被人审驳回的改进意见」时,生成一段强提醒;否则空数组。
* 由 daemon 在 claimOne 时把 store.lastRejectReasonOf 填进 task.lastRejectReason 下发。
*/
function lastRejectLines(task: Task): string[] {
const r = task.lastRejectReason?.trim();
if (!r) return [];
return [
'## 上次被人审驳回的意见(这是返工任务,必须针对性修正)',
'上一版产出被人工审核驳回,驳回意见如下。请把它当作硬性整改要求,逐条落实,不要重复同样的问题:',
'```',
r.length > 2000 ? r.slice(0, 2000) + '\n…(已截断)' : r,
'```',
'',
];
}
/** 声明文件范围约束(task.scopeFiles 非空时注入):改动越界文件会被硬闸拦截、任务打回。 */
function scopeConstraintLines(task: Task): string[] {
const scope = task.scopeFiles?.filter((s) => s.trim());
@@ -99,6 +116,7 @@ export function buildPrompt(task: Task, project?: Project, globalRules?: string
body || '(无详细说明,按标题完成)',
'',
...lastErrorLines(task),
...lastRejectLines(task),
'## 执行约束(必须遵守)',
'- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。',
...scopeConstraintLines(task),
@@ -183,11 +201,13 @@ export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: st
export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project, globalRules?: string | null): string {
const head = [`# 任务:${task.title}`, `任务 ID${task.id}`, '', ...rulesHeaderLines(project, globalRules)];
const errLines = lastErrorLines(task);
const rejectLines = lastRejectLines(task);
if (kind === 'spec') {
if (task.spec) head.push('## 现有方案草稿(可改进/替换)', task.spec, '');
return [
...head,
...errLines,
...rejectLines,
'## 你的角色:方案作者(只读,不改任何文件)',
'只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。',
'## 输出(markdown 正文)',
@@ -201,6 +221,7 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
return [
...head, '',
...errLines,
...rejectLines,
'## 你的角色:任务拆解者(只读,不改任何文件)',
`当前任务层级 depth=${task.depth ?? 1}(根=1),最多拆到 depth=4(剩余可拆层数 ${Math.max(0, 4 - (task.depth ?? 1))} 层)。`,
'只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
+2
View File
@@ -105,6 +105,8 @@ export interface Task {
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
/** 人工驳回意见:daemon 在 claimOne 时从 approvals 取最近一次 reject reason 装配进 job,仅注入 prompt,不持久化 */
lastRejectReason?: string | null;
/** L3 拆解背景:daemon 在 claimOne 时装配进 job(父任务意图 + 兄弟任务状态),仅注入 prompt,不持久化 */
context?: TaskContext | null;
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/
+17
View File
@@ -1070,6 +1070,23 @@ export class Store {
return row?.error ?? null;
}
/**
* 取某任务最近一次「被人审驳回」的改进意见(approvals 里 action='reject')。
* 供把人工驳回意见喂回返工 agent 的 prompt,避免重复犯同样的问题。
* gate 可选:传入则只取该闸的最近一次 rejectexec/spec/plan),与返工角色对齐;
* 不传则取任意闸最近一次。无 reject 返回 null。
*/
lastRejectReasonOf(taskId: string, gate?: GateKind): string | null {
const row = (gate
? this.db.prepare(
`SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND gate = ? AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`,
).get(taskId, gate)
: this.db.prepare(
`SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`,
).get(taskId)) as { reason?: string | null } | undefined;
return row?.reason ?? null;
}
/**
* L3 拆解背景:取某任务的父任务拆解意图(parent.plan)+ 同层兄弟任务概览(标题/状态/复杂度)。
* 无 parentId → null。仅做便宜的 DB 读,不做 git/RAG 检索。
+61
View File
@@ -113,6 +113,67 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind', () =
s.close();
});
const REJECT_SECTION = '上次被人审驳回的意见';
test('REJECT: 无 lastRejectReason 时三处 prompt 都不含驳回段', () => {
assert.ok(!buildPrompt(fakeTask()).includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(REJECT_SECTION));
});
test('REJECT: 有 lastRejectReason 时 buildPrompt/buildPlannerPrompt(spec/decompose) 都注入驳回意见原文', () => {
const reason = '错误地动了 web/ 老仪表盘,请改到 design/ui_kits/console/';
for (const p of [
buildPrompt(fakeTask({ lastRejectReason: reason })),
buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: reason }), 'spec'),
buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRejectReason: reason }), 'decompose'),
]) {
assert.ok(p.includes(REJECT_SECTION), '应含驳回段标题');
assert.ok(p.includes(reason), '应含驳回意见原文');
}
});
test('REJECT: buildConflictPrompt 始终不注入驳回段(守边界)', () => {
const reason = '某条驳回意见';
assert.ok(!buildConflictPrompt(fakeTask({ lastRejectReason: reason }), ['a.ts']).includes(REJECT_SECTION));
});
test('REJECT: 超长驳回意见被截断到 2000 字符', () => {
const reason = 'Y'.repeat(5000);
const p = buildPrompt(fakeTask({ lastRejectReason: reason }));
assert.ok(p.includes('已截断'));
assert.ok(!p.includes('Y'.repeat(2100)));
});
test('REJECT: 顺序——驳回段在角色说明之前;与失败段并存时失败段在前', () => {
// planner 中驳回段排在「你的角色」之前
const sp = buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: 'RJ' }), 'spec');
assert.ok(sp.indexOf(REJECT_SECTION) < sp.indexOf('你的角色'), '驳回段在角色说明之前');
// 失败段与驳回段并存:失败在前、驳回在后
const both = buildPrompt(fakeTask({ lastRunError: 'ERR', lastRejectReason: 'RJ' }));
assert.ok(both.indexOf(SECTION) < both.indexOf(REJECT_SECTION), '失败段在驳回段之前');
});
test('lastRejectReasonOf 取最近一次 reject 的 reason,支持 gate 过滤,无 reject → null', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'rj', repoPath: '/tmp/rj-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
assert.equal(s.lastRejectReasonOf(t.id), null, '无 reject → null');
// 推到 exec_review 再 rejectexec reject 落 ready
s.setOperations(t.id, 'do it');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
const reason = '代码动了越界文件,请收敛范围';
s.decide(t.id, 'reject', 'alice', reason);
assert.equal(s.lastRejectReasonOf(t.id), reason, '不带 gate → 取最近一次');
assert.equal(s.lastRejectReasonOf(t.id, 'exec'), reason, 'exec 闸能取到');
assert.equal(s.lastRejectReasonOf(t.id, 'plan'), null, 'plan 闸取不到该 exec reject');
s.close();
});
test('buildPrompttask.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
assert.match(withScope, /声明的改动文件范围/);