Commit 01987427218e707dd65a44e17c35ea1ecfc54efc
1 parent
9c8deec2
feat: surface the real blocked_pending_human error detail in v_task_detail
User-reported gap: the alert banner only ever showed task.blocked_reason (a coarse category like 'execution_error') with zero indication of the actual cause -- the real message only existed in the matching task_status_changed execution_event's own detail text, with no path to the UI at all. Diagnosing today's several distinct real blockers required querying the DB directly each time, which the user correctly flagged as something the console itself should surface. task_query.get_task() now returns blocked_detail (the most recent blocking event's detail text) when status is blocked_pending_human. v_task_detail shows it as an expandable <details>/<summary> inside the alert banner (no JS wiring needed, matches the plain-vanilla-no-framework convention) so a long or 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 or hidden. Also investigated (not a bug, documented here for the record): two node logs the user found empty really are empty on disk -- execution_log_file rows exist but their target files were never created, for two nodes dating back to the task's original 2026-08-04 run, long before today's fixes. The log viewer's "暂无日志内容" is accurately reporting that state, not a rendering bug. 820/820 tests, frontend smoke passes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
79 additions
and
4 deletions
frontend/app.js
| @@ -1488,12 +1488,27 @@ function mountTaskDetail(taskId, initialOrgNode) { | @@ -1488,12 +1488,27 @@ function mountTaskDetail(taskId, initialOrgNode) { | ||
| 1488 | + '<button class="btn btn-danger" id="btn_stop_task" type="button">停止(stop)</button></div>' | 1488 | + '<button class="btn btn-danger" id="btn_stop_task" type="button">停止(stop)</button></div>' |
| 1489 | ) : ""; | 1489 | ) : ""; |
| 1490 | 1490 | ||
| 1491 | + // 2026-08-13 (real incident): task.blocked_reason alone is only ever a | ||
| 1492 | + // coarse category ('execution_error', ...) -- the actual cause (real | ||
| 1493 | + // exception message / route detail) previously had no path to the UI | ||
| 1494 | + // at all, only recoverable by querying execution_event directly. | ||
| 1495 | + // task_query.get_task() now surfaces it as blocked_detail; shown here | ||
| 1496 | + // as an expandable <details> (no JS wiring needed, matches the "plain | ||
| 1497 | + // vanilla, no framework" convention) so a long/multi-line real error | ||
| 1498 | + // message doesn't force itself into the banner's layout by default, | ||
| 1499 | + // but is one click away in full, not truncated. | ||
| 1500 | + const blockedDetailHtml = task.blocked_detail ? ( | ||
| 1501 | + '<details class="blocked-detail"><summary>查看具体原因</summary>' | ||
| 1502 | + + '<pre class="code">' + escapeHtml(task.blocked_detail) + '</pre></details>' | ||
| 1503 | + ) : ""; | ||
| 1491 | const alertHtml = task.status !== "blocked_pending_human" ? "" : isUserPauseStop ? ( | 1504 | const alertHtml = task.status !== "blocked_pending_human" ? "" : isUserPauseStop ? ( |
| 1492 | - '<div class="alert-banner">' + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止") | ||
| 1493 | - + '<button class="btn btn-primary" id="btn_manual_resume" type="button">继续执行</button></div>' | 1505 | + '<div class="alert-banner"><div class="alert-banner-row">' |
| 1506 | + + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止") | ||
| 1507 | + + '<button class="btn btn-primary" id="btn_manual_resume" type="button">继续执行</button></div></div>' | ||
| 1494 | ) : ( | 1508 | ) : ( |
| 1495 | - '<div class="alert-banner">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' | 1509 | + '<div class="alert-banner"><div class="alert-banner-row">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' |
| 1496 | + '<button class="btn btn-danger" id="btn_manual_resume" type="button">人工干预(manual_resume)</button></div>' | 1510 | + '<button class="btn btn-danger" id="btn_manual_resume" type="button">人工干预(manual_resume)</button></div>' |
| 1511 | + + blockedDetailHtml + '</div>' | ||
| 1497 | ); | 1512 | ); |
| 1498 | 1513 | ||
| 1499 | const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); | 1514 | const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); |
frontend/style.css
| @@ -95,7 +95,15 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | @@ -95,7 +95,15 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | ||
| 95 | 95 | ||
| 96 | .breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 6px; } | 96 | .breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 6px; } |
| 97 | .breadcrumb a { color: #6b7280; } | 97 | .breadcrumb a { color: #6b7280; } |
| 98 | -.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; } | 98 | +.alert-banner { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin: 14px 0; } |
| 99 | +.alert-banner-row { display: flex; justify-content: space-between; align-items: center; } | ||
| 100 | +/* 2026-08-13: task.blocked_detail's expandable full-text reason -- see | ||
| 101 | + v_task_detail's alertHtml comment for why this is a plain <details>, not a | ||
| 102 | + custom JS-wired modal. */ | ||
| 103 | +.blocked-detail { margin-top: 10px; } | ||
| 104 | +.blocked-detail summary { cursor: pointer; color: #991b1b; } | ||
| 105 | +.blocked-detail pre.code { margin: 8px 0 0; max-height: 300px; word-break: break-word; } | ||
| 106 | +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } | ||
| 99 | .btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } | 107 | .btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } |
| 100 | .meta-row { display: flex; gap: 28px; font-size: 13px; margin-bottom: 6px; } | 108 | .meta-row { display: flex; gap: 28px; font-size: 13px; margin-bottom: 6px; } |
| 101 | .meta-row b { display: block; font-size: 11px; color: #6b7280; font-weight: 600; } | 109 | .meta-row b { display: block; font-size: 11px; color: #6b7280; font-weight: 600; } |
src/task_query.py
| @@ -150,6 +150,27 @@ def get_task(conn, task_id): | @@ -150,6 +150,27 @@ def get_task(conn, task_id): | ||
| 150 | "created_at": row[11], "updated_at": row[12], "execution_priority": row[13], | 150 | "created_at": row[11], "updated_at": row[12], "execution_priority": row[13], |
| 151 | } | 151 | } |
| 152 | 152 | ||
| 153 | + # 2026-08-13 (real incident): task.blocked_reason is only ever a coarse | ||
| 154 | + # category ('execution_error', ...) -- the actual human-readable cause | ||
| 155 | + # (task_execution._mark_blocked's str(exc), or org_loop_route's own | ||
| 156 | + # detail strings) only ever lived in the matching task_status_changed | ||
| 157 | + # execution_event's payload, which v_task_detail's alert banner never | ||
| 158 | + # surfaced -- so a blocked task showed "execution_error, needs human | ||
| 159 | + # intervention" with zero way to tell WHY short of querying the DB | ||
| 160 | + # directly. Most recent event landing on blocked_pending_human, if any. | ||
| 161 | + if task["status"] == "blocked_pending_human": | ||
| 162 | + event_row = conn.execute( | ||
| 163 | + "SELECT payload FROM execution_event WHERE task_id = ? AND event_type = 'task_status_changed' " | ||
| 164 | + "ORDER BY created_at DESC, rowid DESC LIMIT 20", | ||
| 165 | + (task_id,), | ||
| 166 | + ).fetchall() | ||
| 167 | + task["blocked_detail"] = None | ||
| 168 | + for (payload_json,) in event_row: | ||
| 169 | + payload = json.loads(payload_json) | ||
| 170 | + if payload.get("to_status") == "blocked_pending_human" and payload.get("detail"): | ||
| 171 | + task["blocked_detail"] = payload["detail"] | ||
| 172 | + break | ||
| 173 | + | ||
| 153 | artifacts = conn.execute( | 174 | artifacts = conn.execute( |
| 154 | "SELECT artifact_id, artifact_type, produced_by_run_id, file_path, git_commit_sha, " | 175 | "SELECT artifact_id, artifact_type, produced_by_run_id, file_path, git_commit_sha, " |
| 155 | "validation_status, created_at FROM artifact WHERE task_id = ? ORDER BY created_at", | 176 | "validation_status, created_at FROM artifact WHERE task_id = ? ORDER BY created_at", |
src/test_task_query.py
| @@ -92,6 +92,37 @@ class GetTaskTest(TaskQueryTestBase): | @@ -92,6 +92,37 @@ class GetTaskTest(TaskQueryTestBase): | ||
| 92 | self.assertEqual(result["work_graph_runs"], []) | 92 | self.assertEqual(result["work_graph_runs"], []) |
| 93 | self.assertEqual(result["planner_review_events"], []) | 93 | self.assertEqual(result["planner_review_events"], []) |
| 94 | 94 | ||
| 95 | + def test_blocked_detail_absent_when_not_blocked(self): | ||
| 96 | + self._insert_task("task-1", status="coding") | ||
| 97 | + result = task_query.get_task(self.conn, "task-1") | ||
| 98 | + self.assertNotIn("blocked_detail", result) | ||
| 99 | + | ||
| 100 | + def test_blocked_detail_none_when_no_detail_recorded(self): | ||
| 101 | + self._insert_task("task-1", status="blocked_pending_human") | ||
| 102 | + result = task_query.get_task(self.conn, "task-1") | ||
| 103 | + self.assertIsNone(result["blocked_detail"]) | ||
| 104 | + | ||
| 105 | + def test_blocked_detail_from_most_recent_blocking_event(self): | ||
| 106 | + # 2026-08-13 (real incident): task.blocked_reason is only ever a | ||
| 107 | + # coarse category ('execution_error') -- the actual cause lives in | ||
| 108 | + # the matching task_status_changed event's own detail text. | ||
| 109 | + self._insert_task("task-1", status="blocked_pending_human") | ||
| 110 | + self.conn.execute( | ||
| 111 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 112 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " | ||
| 113 | + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\", " | ||
| 114 | + "\"detail\": \"first real reason\"}')" | ||
| 115 | + ) | ||
| 116 | + self.conn.execute( | ||
| 117 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 118 | + "VALUES ('evt-2', 'proj-1', 'task-1', 'task_status_changed', " | ||
| 119 | + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\", " | ||
| 120 | + "\"detail\": \"second, more recent reason\"}')" | ||
| 121 | + ) | ||
| 122 | + self.conn.commit() | ||
| 123 | + result = task_query.get_task(self.conn, "task-1") | ||
| 124 | + self.assertEqual(result["blocked_detail"], "second, more recent reason") | ||
| 125 | + | ||
| 95 | def test_includes_artifacts(self): | 126 | def test_includes_artifacts(self): |
| 96 | self._insert_task("task-1") | 127 | self._insert_task("task-1") |
| 97 | self.conn.execute( | 128 | self.conn.execute( |