Commit 01987427218e707dd65a44e17c35ea1ecfc54efc

Authored by bboonnee
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>
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(