⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预'
+ '
⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预'
+ '人工干预(manual_resume)
'
+ + 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(