feat(daemon+store+api): verdict硬闸+执行期锁+conflict优先+schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -204,6 +204,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
// 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask)
|
// 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask)
|
||||||
app.patch('/api/tasks/:id', (req) => {
|
app.patch('/api/tasks/:id', (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
if (store.hasInflightRun(id)) {
|
||||||
|
throw new StoreError('任务有在途 run,执行期间禁止修改');
|
||||||
|
}
|
||||||
const b = (req.body ?? {}) as Record<string, unknown>;
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||||
const patch: PatchTaskInput = {};
|
const patch: PatchTaskInput = {};
|
||||||
if (b.title !== undefined) patch.title = String(b.title);
|
if (b.title !== undefined) patch.title = String(b.title);
|
||||||
|
|||||||
+43
-1
@@ -85,14 +85,36 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
|
|||||||
if (!task) { log.error(`decompose: 任务不存在 ${taskId}`); return; }
|
if (!task) { log.error(`decompose: 任务不存在 ${taskId}`); return; }
|
||||||
store.setPlan(taskId, rec.plan || '(无分析说明)');
|
store.setPlan(taskId, rec.plan || '(无分析说明)');
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
const createdIds: string[] = [];
|
||||||
for (const sub of rec.subtasks) {
|
for (const sub of rec.subtasks) {
|
||||||
try {
|
try {
|
||||||
store.createTask({ projectId: task.projectId, parentId: taskId, title: sub.title, complexity: sub.complexity });
|
const priority = typeof (sub as { priority?: unknown }).priority === 'number'
|
||||||
|
? (sub as { priority: number }).priority : 1;
|
||||||
|
const newTask = store.createTask({ projectId: task.projectId, parentId: taskId, title: sub.title, complexity: sub.complexity, priority });
|
||||||
|
createdIds.push(newTask.id);
|
||||||
created++;
|
created++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(`decompose: 建子任务「${sub.title}」失败:${(e as Error).message}`);
|
log.error(`decompose: 建子任务「${sub.title}」失败:${(e as Error).message}`);
|
||||||
|
createdIds.push(''); // 占位,保持序号对齐
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 序号 → taskId 映射,写入 deps
|
||||||
|
for (let i = 0; i < rec.subtasks.length; i++) {
|
||||||
|
const sub = rec.subtasks[i] as { deps?: number[] };
|
||||||
|
if (!sub.deps?.length || !createdIds[i]) continue;
|
||||||
|
const depIds = sub.deps
|
||||||
|
.filter((idx) => idx >= 0 && idx < createdIds.length && createdIds[idx])
|
||||||
|
.map((idx) => createdIds[idx]);
|
||||||
|
if (depIds.length > 0) {
|
||||||
|
try {
|
||||||
|
store.patchTask(createdIds[i], { deps: depIds });
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`decompose: 写子任务「${rec.subtasks[i].title}」deps 失败:${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
store.transition(taskId, 'plan_review', { by: 'ingest', runId, subtasks: created });
|
store.transition(taskId, 'plan_review', { by: 'ingest', runId, subtasks: created });
|
||||||
store.finishRun(runId, 'succeeded', {
|
store.finishRun(runId, 'succeeded', {
|
||||||
transcriptRef: rec.transcriptRef ?? undefined,
|
transcriptRef: rec.transcriptRef ?? undefined,
|
||||||
@@ -122,6 +144,26 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
|
|||||||
securityVerdict: rec.security.verdict,
|
securityVerdict: rec.security.verdict,
|
||||||
mergeTaskId: null,
|
mergeTaskId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// verdict 硬闸:任一 reject → 退回重执行,不进 exec_review
|
||||||
|
const codeReject = rec.code.verdict === 'reject';
|
||||||
|
const secReject = rec.security.verdict === 'reject';
|
||||||
|
if (codeReject || secReject) {
|
||||||
|
const reason = [
|
||||||
|
codeReject ? `代码复审拒绝:${rec.code.summary?.slice(0, 200)}` : '',
|
||||||
|
secReject ? `安全审计拒绝:${rec.security.summary?.slice(0, 200)}` : '',
|
||||||
|
].filter(Boolean).join(';');
|
||||||
|
store.finishRun(runId, 'succeeded', {
|
||||||
|
transcriptRef: rec.executor.transcriptRef ?? undefined,
|
||||||
|
claudeSessionId: rec.executor.sessionId ?? undefined,
|
||||||
|
});
|
||||||
|
// 退回重执行(带复审意见)
|
||||||
|
store.failTaskAttempt(taskId, null, `自动复审拒绝,退回重执行:${reason}`);
|
||||||
|
log.info(`task=${taskId} run=${runId} 复审拒绝 → 退回重执行`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双 approve → 正常流程
|
||||||
store.transition(taskId, 'exec_review', { by: 'ingest', runId });
|
store.transition(taskId, 'exec_review', { by: 'ingest', runId });
|
||||||
store.finishRun(runId, 'succeeded', {
|
store.finishRun(runId, 'succeeded', {
|
||||||
transcriptRef: rec.executor.transcriptRef ?? undefined,
|
transcriptRef: rec.executor.transcriptRef ?? undefined,
|
||||||
|
|||||||
@@ -137,7 +137,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
|||||||
if (t.nextEligibleAt && nowMs < Date.parse(t.nextEligibleAt)) return false; // 退避冷却中
|
if (t.nextEligibleAt && nowMs < Date.parse(t.nextEligibleAt)) return false; // 退避冷却中
|
||||||
return t.deps.every((dep) => byId.get(dep)?.status === 'done');
|
return t.deps.every((dep) => byId.get(dep)?.status === 'done');
|
||||||
});
|
});
|
||||||
return rankByScore(candidates, tasks);
|
const isMergeRemediation = (t: Task): boolean =>
|
||||||
|
t.priority === 0 && t.title.startsWith('合并冲突待解决');
|
||||||
|
const ranked = rankByScore(candidates, tasks);
|
||||||
|
ranked.sort((a, b) => {
|
||||||
|
const aMR = isMergeRemediation(a.task) ? 1 : 0;
|
||||||
|
const bMR = isMergeRemediation(b.task) ? 1 : 0;
|
||||||
|
if (bMR !== aMR) return bMR - aMR;
|
||||||
|
return b.score - a.score;
|
||||||
|
});
|
||||||
|
return ranked;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ProjectRow {
|
|||||||
concurrency: number; max_retries: number; timeout_ms: number;
|
concurrency: number; max_retries: number; timeout_ms: number;
|
||||||
status: string; created_at: string;
|
status: string; created_at: string;
|
||||||
last_sync_at: string | null; logo: string | null; sort_order: number;
|
last_sync_at: string | null; logo: string | null; sort_order: number;
|
||||||
|
checks: string | null; auto_approve_plan: number; auto_approve_exec: number;
|
||||||
}
|
}
|
||||||
export interface TaskRow {
|
export interface TaskRow {
|
||||||
id: string; project_id: string; parent_id: string | null; depth: number;
|
id: string; project_id: string; parent_id: string | null; depth: number;
|
||||||
@@ -41,6 +42,9 @@ export function rowToProject(r: ProjectRow): Project {
|
|||||||
concurrency: r.concurrency, maxRetries: r.max_retries ?? 2, timeoutMs: r.timeout_ms ?? 1_800_000,
|
concurrency: r.concurrency, maxRetries: r.max_retries ?? 2, timeoutMs: r.timeout_ms ?? 1_800_000,
|
||||||
status: r.status as Project['status'], createdAt: r.created_at,
|
status: r.status as Project['status'], createdAt: r.created_at,
|
||||||
lastSyncAt: r.last_sync_at, logo: r.logo, sortOrder: r.sort_order,
|
lastSyncAt: r.last_sync_at, logo: r.logo, sortOrder: r.sort_order,
|
||||||
|
checks: r.checks ?? null,
|
||||||
|
autoApprovePlan: Boolean(r.auto_approve_plan),
|
||||||
|
autoApproveExec: Boolean(r.auto_approve_exec),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
last_sync_at TEXT, -- 最近一次 todo.json 同步时间
|
last_sync_at TEXT, -- 最近一次 todo.json 同步时间
|
||||||
logo TEXT, -- 自定义 logo(URL / 仓库内相对路径;null=自动)
|
logo TEXT, -- 自定义 logo(URL / 仓库内相对路径;null=自动)
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0 -- 侧栏排序(小在前)
|
sort_order INTEGER NOT NULL DEFAULT 0, -- 侧栏排序(小在前)
|
||||||
|
checks TEXT, -- 分项检查命令 JSON(如 {"lint":"npm run lint"})
|
||||||
|
auto_approve_plan INTEGER NOT NULL DEFAULT 0, -- 全 easy 子任务时跳过 plan_review(0=关)
|
||||||
|
auto_approve_exec INTEGER NOT NULL DEFAULT 0 -- 双复审 approve 后跳过 exec_review(0=关)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
|||||||
+19
-2
@@ -110,10 +110,11 @@ export class Store {
|
|||||||
timeout_ms: input.timeoutMs ?? 1_800_000,
|
timeout_ms: input.timeoutMs ?? 1_800_000,
|
||||||
status: 'active', created_at: now(),
|
status: 'active', created_at: now(),
|
||||||
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
|
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
|
||||||
|
checks: null, auto_approve_plan: 0, auto_approve_exec: 0,
|
||||||
};
|
};
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order)
|
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order,checks,auto_approve_plan,auto_approve_exec)
|
||||||
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@max_retries,@timeout_ms,@status,@created_at,@last_sync_at,@logo,@sort_order)`,
|
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@max_retries,@timeout_ms,@status,@created_at,@last_sync_at,@logo,@sort_order,@checks,@auto_approve_plan,@auto_approve_exec)`,
|
||||||
).run(row);
|
).run(row);
|
||||||
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
|
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
|
||||||
return rowToProject(row);
|
return rowToProject(row);
|
||||||
@@ -394,6 +395,7 @@ export class Store {
|
|||||||
patchTask(taskId: string, patch: PatchTaskInput): Task {
|
patchTask(taskId: string, patch: PatchTaskInput): Task {
|
||||||
let row = this.getTaskRow(taskId);
|
let row = this.getTaskRow(taskId);
|
||||||
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
||||||
|
this.assertNotInflight(taskId);
|
||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
let depsChanged = false;
|
let depsChanged = false;
|
||||||
@@ -701,6 +703,21 @@ export class Store {
|
|||||||
this.db.prepare(`UPDATE tasks SET next_eligible_at = ?, updated_at = ? WHERE id = ?`).run(iso, now(), taskId);
|
this.db.prepare(`UPDATE tasks SET next_eligible_at = ?, updated_at = ? WHERE id = ?`).run(iso, now(), taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任务是否有在途 run(status='started')*/
|
||||||
|
hasInflightRun(taskId: string): boolean {
|
||||||
|
const row = this.db.prepare(
|
||||||
|
`SELECT COUNT(*) as cnt FROM runs WHERE task_id = ? AND status = 'started'`,
|
||||||
|
).get(taskId) as { cnt: number };
|
||||||
|
return row.cnt > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 若任务有在途 run 则抛错(用于保护 user mutation)*/
|
||||||
|
private assertNotInflight(taskId: string): void {
|
||||||
|
if (this.hasInflightRun(taskId)) {
|
||||||
|
throw new StoreError(`任务 ${taskId} 有在途 run,执行期间禁止修改`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 项目内"有 started run 的任务" id 集合(多进程并发闸 + claimable 排除在途;executor 与 planner 通用)。 */
|
/** 项目内"有 started run 的任务" id 集合(多进程并发闸 + claimable 排除在途;executor 与 planner 通用)。 */
|
||||||
inflightTaskIds(projectId: string): Set<string> {
|
inflightTaskIds(projectId: string): Set<string> {
|
||||||
const rows = this.db.prepare(
|
const rows = this.db.prepare(
|
||||||
|
|||||||
Reference in New Issue
Block a user