feat(web+store): needs_attention 任务显示在审核区,附失败原因

- pendingApprovals 扩展到包含 needs_attention,subquery 取最近 executor 失败 error
- Task 类型增加 lastRunError 字段,mappers 映射
- 审核区(renderGates)将 needs_attention 渲染为独立橙色卡片,
  展示「需人工确认」标签 + 具体失败原因 + 重新排队/取消按钮
- i18n zh/en 补充 attnLabel/attnReason/attnRequeue/attnCancel/toastRequeued

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-14 14:46:21 +08:00
parent 4a2b4e1094
commit 433b2dc232
5 changed files with 59 additions and 6 deletions
+1
View File
@@ -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;
}
+2
View File
@@ -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,
};
}
+5 -2
View File
@@ -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[];
+46 -4
View File
@@ -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' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : '';
@@ -888,7 +896,31 @@ function renderGates() {
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('collapse')}</button>
</div>` : ''}
</div>`;
}).join('');
});
const attnCards = attnTasks.map((ap) => {
const reason = ap.lastRunError || t('attnNoReason');
return `
<div class="gate-card gate-card-attn">
<div class="gate-card-head">
<span class="gate-kind gate-kind-attn">${t('attnLabel')}</span>
<span class="gate-title">${esc(ap.title)}</span>
${cplxBadge(ap.complexity)}
${statusChip(ap.status)}
</div>
<div class="gate-body">
<div class="gate-doc-label">${t('attnReason')}</div>
<pre class="doc attn-reason">${esc(reason)}</pre>
</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="task-requeue" data-id="${esc(ap.id)}">${t('attnRequeue')}</button>
<button class="btn btn-reject" data-action="task-cancel" data-id="${esc(ap.id)}">${t('attnCancel')}</button>
<span class="proj-meta">id ${esc(ap.id)} · ${fmtTime(ap.updatedAt)}</span>
</div>
</div>`;
});
$('#gateList').innerHTML = [...attnCards, ...gateCards].join('');
}
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
@@ -1738,6 +1770,16 @@ document.addEventListener('click', (ev) => {
break;
}
case 'task-requeue':
act(() => api(`/api/tasks/${id}/requeue`, { method: 'POST' }), t('toastRequeued'));
break;
case 'task-cancel':
act(() => api(`/api/tasks/${id}/transition`, {
method: 'POST', body: JSON.stringify({ status: 'cancelled' }),
}), t('toastCancelled'));
break;
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
renderGates();
+5
View File
@@ -398,6 +398,11 @@ pre.doc.empty { color: var(--faint); border-left-color: var(--line); }
border-top: 1px solid var(--line-soft);
}
/* needs_attention 卡片 */
.gate-card-attn { border-color: var(--orange-dim, #7a4a1a); }
.gate-kind-attn { background: var(--orange, #c96b00); }
pre.doc.attn-reason { border-left-color: var(--orange, #c96b00); color: var(--orange-text, #ffb870); white-space: pre-wrap; max-height: 120px; }
.reject-form { padding: 0 14px 12px; display: flex; gap: 10px; align-items: flex-start; }
.reject-form textarea {
flex: 1; min-height: 56px;