From f8900de2f73d12963197867213a9780416b2552d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 14 Jun 2026 12:27:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(daemon+store+api):=20verdict=E7=A1=AC?= =?UTF-8?q?=E9=97=B8+=E6=89=A7=E8=A1=8C=E6=9C=9F=E9=94=81+conflict?= =?UTF-8?q?=E4=BC=98=E5=85=88+schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api/server.ts | 3 +++ src/daemon/ingest.ts | 44 +++++++++++++++++++++++++++++++++++++- src/daemon/orchestrator.ts | 11 +++++++++- src/store/mappers.ts | 4 ++++ src/store/schema.sql | 5 ++++- src/store/store.ts | 21 ++++++++++++++++-- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 3958030..60f2ba1 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -204,6 +204,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance { // 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask) app.patch('/api/tasks/:id', (req) => { const { id } = req.params as { id: string }; + if (store.hasInflightRun(id)) { + throw new StoreError('任务有在途 run,执行期间禁止修改'); + } const b = (req.body ?? {}) as Record; const patch: PatchTaskInput = {}; if (b.title !== undefined) patch.title = String(b.title); diff --git a/src/daemon/ingest.ts b/src/daemon/ingest.ts index 5fa3bad..df89f1f 100644 --- a/src/daemon/ingest.ts +++ b/src/daemon/ingest.ts @@ -85,14 +85,36 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str if (!task) { log.error(`decompose: 任务不存在 ${taskId}`); return; } store.setPlan(taskId, rec.plan || '(无分析说明)'); let created = 0; + const createdIds: string[] = []; for (const sub of rec.subtasks) { 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++; } catch (e) { 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.finishRun(runId, 'succeeded', { transcriptRef: rec.transcriptRef ?? undefined, @@ -122,6 +144,26 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str securityVerdict: rec.security.verdict, 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.finishRun(runId, 'succeeded', { transcriptRef: rec.executor.transcriptRef ?? undefined, diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index c11ea2a..a4c3b56 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -137,7 +137,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: if (t.nextEligibleAt && nowMs < Date.parse(t.nextEligibleAt)) return false; // 退避冷却中 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; } /** diff --git a/src/store/mappers.ts b/src/store/mappers.ts index 81d5a9f..d399784 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -10,6 +10,7 @@ export interface ProjectRow { concurrency: number; max_retries: number; timeout_ms: number; status: string; created_at: string; 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 { 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, status: r.status as Project['status'], createdAt: r.created_at, 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), }; } diff --git a/src/store/schema.sql b/src/store/schema.sql index 8fff1c7..93ca11f 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -17,7 +17,10 @@ CREATE TABLE IF NOT EXISTS projects ( created_at TEXT NOT NULL, last_sync_at TEXT, -- 最近一次 todo.json 同步时间 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 ( diff --git a/src/store/store.ts b/src/store/store.ts index 32bca9c..0626d86 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -110,10 +110,11 @@ export class Store { timeout_ms: input.timeoutMs ?? 1_800_000, status: 'active', created_at: now(), last_sync_at: null, logo: null, sort_order: maxOrder + 1, + checks: null, auto_approve_plan: 0, auto_approve_exec: 0, }; 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) - 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)`, + `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,@checks,@auto_approve_plan,@auto_approve_exec)`, ).run(row); this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name }); return rowToProject(row); @@ -394,6 +395,7 @@ export class Store { patchTask(taskId: string, patch: PatchTaskInput): Task { let row = this.getTaskRow(taskId); if (!row) throw new StoreError(`任务不存在: ${taskId}`); + this.assertNotInflight(taskId); const fields: string[] = []; 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); } + /** 任务是否有在途 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 通用)。 */ inflightTaskIds(projectId: string): Set { const rows = this.db.prepare(