merge: maestro/tsk_BJLguXuWkyq4 [tsk_BJLguXuWkyq4]
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -1070,6 +1070,23 @@ export class Store {
|
||||
return row?.error ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某任务最近一次「被人审驳回」的改进意见(approvals 里 action='reject')。
|
||||
* 供把人工驳回意见喂回返工 agent 的 prompt,避免重复犯同样的问题。
|
||||
* gate 可选:传入则只取该闸的最近一次 reject(exec/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 检索。
|
||||
|
||||
@@ -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 再 reject(exec 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('buildPrompt:task.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
|
||||
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
|
||||
assert.match(withScope, /声明的改动文件范围/);
|
||||
|
||||
Reference in New Issue
Block a user