diff --git a/src/model/types.ts b/src/model/types.ts index c6e319c..7767e55 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -76,6 +76,7 @@ export interface Task { assignee: 'agent' | 'human' | null; retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用) nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领 + lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示) createdAt: string; updatedAt: string; } diff --git a/src/store/mappers.ts b/src/store/mappers.ts index d399784..586650b 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -20,6 +20,7 @@ export interface TaskRow { next_eligible_at: string | null; created_at: string; updated_at: string; source_ref: string | null; + last_run_error?: string | null; // pendingApprovals 扩展字段(subquery) } export interface ApprovalRow { id: string; task_id: string; gate: string; action: string; @@ -75,6 +76,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task { approvals, result: r.result ? parseResult(r.result) : null, assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0, nextEligibleAt: r.next_eligible_at ?? null, + lastRunError: r.last_run_error ?? null, createdAt: r.created_at, updatedAt: r.updated_at, }; } diff --git a/src/store/store.ts b/src/store/store.ts index 0626d86..3947673 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -825,9 +825,12 @@ export class Store { /** 列出所有处于审批闸状态(plan_review/spec_review/exec_review)的任务。 */ pendingApprovals(projectId?: string): Task[] { + // 包含 needs_attention:让审核区展示失败任务供人工确认/重排 + // last_run_error 由子查询取最近一条 failed executor run 的 error + const sub = `(SELECT error FROM runs WHERE task_id = t.id AND status = 'failed' AND kind = 'executor' ORDER BY ended_at DESC LIMIT 1)`; const sql = projectId - ? `SELECT * FROM tasks WHERE project_id = ? AND status IN ('plan_review','spec_review','exec_review') ORDER BY updated_at` - : `SELECT * FROM tasks WHERE status IN ('plan_review','spec_review','exec_review') ORDER BY updated_at`; + ? `SELECT t.*, ${sub} AS last_run_error FROM tasks t WHERE t.project_id = ? AND t.status IN ('plan_review','spec_review','exec_review','needs_attention') ORDER BY t.updated_at` + : `SELECT t.*, ${sub} AS last_run_error FROM tasks t WHERE t.status IN ('plan_review','spec_review','exec_review','needs_attention') ORDER BY t.updated_at`; const rows = (projectId ? this.db.prepare(sql).all(projectId) : this.db.prepare(sql).all()) as TaskRow[]; diff --git a/web/app.js b/web/app.js index 3820116..e0f64ea 100644 --- a/web/app.js +++ b/web/app.js @@ -29,6 +29,8 @@ const I18N = { agentSection: 'Agent 执行', agentEmpty: '无 agent 在执行', running: 'RUNNING', since: '开始', usageUnavail: '额度信息不可用', quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置', gateSection: '审批闸 · 等待裁决', gatePreview: '⛶ 预览', + attnLabel: '需人工确认', attnReason: '失败原因', attnNoReason: '失败原因未记录', + attnRequeue: '↺ 重新排队', attnCancel: '✕ 取消任务', gateAcceptMerge: '✓ 通过并合并', gateAccept: '✓ 接受', gateOnlyPass: '仅通过', gateMergeNote: '通过 = 自动把任务分支合并到 {branch}(仅本地合并,不 push)', gateReject: '✗ 拒绝', rejectPlaceholder: '驳回意见(必填)——说明问题与改进方向', @@ -111,7 +113,7 @@ const I18N = { toastSaved: '配置已保存', toastSynced: '同步完成:新建 {created} · 推进 done {done} · 跳过 {skipped}', toastWsSynced: 'todo 同步:新建 {created} · 推进 done {done} · 跳过 {skipped}', - toastCancelled: '任务已取消', toastDeleted: '任务已删除', + toastCancelled: '任务已取消', toastRequeued: '任务已重新排队', toastDeleted: '任务已删除', toastDeletedWithSubs: '任务已删除(含 {n} 子孙)', toastArchiveDeleted: '归档任务已删除', toastArchiveDeletedSubs: '归档任务已删除(含 {n} 子孙)', toastDepsUpdated: '依赖已更新({n} 项,按依赖重算 ready/blocked)', @@ -160,6 +162,8 @@ const I18N = { agentSection: 'Agent runs', agentEmpty: 'No agents running', running: 'RUNNING', since: '', usageUnavail: 'Usage info unavailable', quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}', gateSection: 'Approval gates · awaiting decision', gatePreview: '⛶ Preview', + attnLabel: 'Needs attention', attnReason: 'Failure reason', attnNoReason: 'No failure reason recorded', + attnRequeue: '↺ Re-queue', attnCancel: '✕ Cancel task', gateAcceptMerge: '✓ Accept & merge', gateAccept: '✓ Accept', gateOnlyPass: 'Pass only', gateMergeNote: 'Accept = merge task branch into {branch} (local only, no push)', gateReject: '✗ Reject', rejectPlaceholder: 'Rejection feedback (required) — describe issues', @@ -242,7 +246,7 @@ const I18N = { toastSaved: 'Config saved', toastSynced: 'Synced: {created} created · {done} advanced · {skipped} skipped', toastWsSynced: 'Todo sync: {created} created · {done} advanced · {skipped} skipped', - toastCancelled: 'Task cancelled', toastDeleted: 'Task deleted', + toastCancelled: 'Task cancelled', toastRequeued: 'Task re-queued', toastDeleted: 'Task deleted', toastDeletedWithSubs: 'Task deleted ({n} descendants)', toastArchiveDeleted: 'Archive deleted', toastArchiveDeletedSubs: 'Archive deleted ({n} descendants)', toastDepsUpdated: 'Dependencies updated ({n} items, ready/blocked recalculated)', @@ -859,7 +863,11 @@ function renderGates() { if (ico) gateHead.appendChild(ico); gateHead.appendChild(document.createTextNode(' ' + t('gateSection'))); } - $('#gateList').innerHTML = S.approvals.map((ap) => { + + const gateApprovals = S.approvals.filter((ap) => ap.status !== 'needs_attention'); + const attnTasks = S.approvals.filter((ap) => ap.status === 'needs_attention'); + + const gateCards = gateApprovals.map((ap) => { const gate = GATE_OF[ap.status]; const rejOpen = S.rejectOpen.has(ap.id); const mergeNote = gate === 'exec' ? `` : ''; @@ -888,7 +896,31 @@ function renderGates() { ` : ''} `; - }).join(''); + }); + + const attnCards = attnTasks.map((ap) => { + const reason = ap.lastRunError || t('attnNoReason'); + return ` +
${esc(reason)}
+