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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user