From 01987427218e707dd65a44e17c35ea1ecfc54efc Mon Sep 17 00:00:00 2001 From: bboonnee Date: Thu, 13 Aug 2026 19:25:22 +0800 Subject: [PATCH] feat: surface the real blocked_pending_human error detail in v_task_detail --- frontend/app.js | 21 ++++++++++++++++++--- frontend/style.css | 10 +++++++++- src/task_query.py | 21 +++++++++++++++++++++ src/test_task_query.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 40bb343..42859c5 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1488,12 +1488,27 @@ function mountTaskDetail(taskId, initialOrgNode) { + '' ) : ""; + // 2026-08-13 (real incident): task.blocked_reason alone is only ever a + // coarse category ('execution_error', ...) -- the actual cause (real + // exception message / route detail) previously had no path to the UI + // at all, only recoverable by querying execution_event directly. + // task_query.get_task() now surfaces it as blocked_detail; shown here + // as an expandable
(no JS wiring needed, matches the "plain + // vanilla, no framework" convention) so a long/multi-line real error + // message doesn't force itself into the banner's layout by default, + // but is one click away in full, not truncated. + const blockedDetailHtml = task.blocked_detail ? ( + '
查看具体原因' + + '
' + escapeHtml(task.blocked_detail) + '
' + ) : ""; const alertHtml = task.status !== "blocked_pending_human" ? "" : isUserPauseStop ? ( - '
' + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止") - + '
' + '
' + + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止") + + '
' ) : ( - '
⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' + '
⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' + '
' + + blockedDetailHtml + '
' ); const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); diff --git a/frontend/style.css b/frontend/style.css index 1ff29f6..14a1f01 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -95,7 +95,15 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } .breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 6px; } .breadcrumb a { color: #6b7280; } -.alert-banner { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin: 14px 0; display: flex; justify-content: space-between; align-items: center; } +.alert-banner { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin: 14px 0; } +.alert-banner-row { display: flex; justify-content: space-between; align-items: center; } +/* 2026-08-13: task.blocked_detail's expandable full-text reason -- see + v_task_detail's alertHtml comment for why this is a plain
, not a + custom JS-wired modal. */ +.blocked-detail { margin-top: 10px; } +.blocked-detail summary { cursor: pointer; color: #991b1b; } +.blocked-detail pre.code { margin: 8px 0 0; max-height: 300px; word-break: break-word; } +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } .btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } .meta-row { display: flex; gap: 28px; font-size: 13px; margin-bottom: 6px; } .meta-row b { display: block; font-size: 11px; color: #6b7280; font-weight: 600; } diff --git a/src/task_query.py b/src/task_query.py index 690c999..c3b5459 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -150,6 +150,27 @@ def get_task(conn, task_id): "created_at": row[11], "updated_at": row[12], "execution_priority": row[13], } + # 2026-08-13 (real incident): task.blocked_reason is only ever a coarse + # category ('execution_error', ...) -- the actual human-readable cause + # (task_execution._mark_blocked's str(exc), or org_loop_route's own + # detail strings) only ever lived in the matching task_status_changed + # execution_event's payload, which v_task_detail's alert banner never + # surfaced -- so a blocked task showed "execution_error, needs human + # intervention" with zero way to tell WHY short of querying the DB + # directly. Most recent event landing on blocked_pending_human, if any. + if task["status"] == "blocked_pending_human": + event_row = conn.execute( + "SELECT payload FROM execution_event WHERE task_id = ? AND event_type = 'task_status_changed' " + "ORDER BY created_at DESC, rowid DESC LIMIT 20", + (task_id,), + ).fetchall() + task["blocked_detail"] = None + for (payload_json,) in event_row: + payload = json.loads(payload_json) + if payload.get("to_status") == "blocked_pending_human" and payload.get("detail"): + task["blocked_detail"] = payload["detail"] + break + artifacts = conn.execute( "SELECT artifact_id, artifact_type, produced_by_run_id, file_path, git_commit_sha, " "validation_status, created_at FROM artifact WHERE task_id = ? ORDER BY created_at", diff --git a/src/test_task_query.py b/src/test_task_query.py index dcdd2ed..83490df 100644 --- a/src/test_task_query.py +++ b/src/test_task_query.py @@ -92,6 +92,37 @@ class GetTaskTest(TaskQueryTestBase): self.assertEqual(result["work_graph_runs"], []) self.assertEqual(result["planner_review_events"], []) + def test_blocked_detail_absent_when_not_blocked(self): + self._insert_task("task-1", status="coding") + result = task_query.get_task(self.conn, "task-1") + self.assertNotIn("blocked_detail", result) + + def test_blocked_detail_none_when_no_detail_recorded(self): + self._insert_task("task-1", status="blocked_pending_human") + result = task_query.get_task(self.conn, "task-1") + self.assertIsNone(result["blocked_detail"]) + + def test_blocked_detail_from_most_recent_blocking_event(self): + # 2026-08-13 (real incident): task.blocked_reason is only ever a + # coarse category ('execution_error') -- the actual cause lives in + # the matching task_status_changed event's own detail text. + self._insert_task("task-1", status="blocked_pending_human") + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\", " + "\"detail\": \"first real reason\"}')" + ) + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-2', 'proj-1', 'task-1', 'task_status_changed', " + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\", " + "\"detail\": \"second, more recent reason\"}')" + ) + self.conn.commit() + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(result["blocked_detail"], "second, more recent reason") + def test_includes_artifacts(self): self._insert_task("task-1") self.conn.execute( -- libgit2 0.21.4