Commit a9b5f08207eb4d8e144516896367877debd81d55

Authored by bboonnee
1 parent add263a9

Deploy agent definitions on project creation; surface real blocked-task errors

New target projects never got .claude/agents/*.md deployed, so their first
Planner call always failed immediately (claude --agent planner finds nothing
in cwd) -- self-target usage never noticed since those files are already
committed as part of this repo. create_project() now deploys and commits
them alongside the existing .graphlemon/ init commit.

Also: unroutable execution failures were recorded as a bare
blocked_reason='execution_error' with no way to see what actually broke.
scheduler._block_task()/task_execution._mark_blocked() now persist the real
exception message into execution_event.payload (blocked_reason itself is
unchanged -- budget_guardrail matches its exact value elsewhere).

Found via a real console Start-button failure on a freshly created project;
see docs/implementation-gap-analysis.md for the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/implementation-gap-analysis.md 0 → 100644
  1 +# 实现现状 vs 目标/BRD/PRD/SDD 差距分析
  2 +
  3 +**日期**: 2026-08-03
  4 +**性质**: 现状快照,回应用户 "现在整个系统的实现和我们的文档的 goal 和 brd prd sdd 还有哪些差距"。基于对 `git log`、
  5 +`docs/development-plan.md`(含各 Unit 事后补记)、`docs/build-report.md`、`docs/sdd/06-验收与测试锚点.md` 和实际
  6 +代码(`src/`、`supervisor/`)的交叉核对得出,不是重新跑一遍验收。
  7 +**结论先说**: 45 个 Unit(U-001~U-043 + U-004.5/U-024.5)全部有对应实现和提交,`docs/build-report.md` 的
  8 +"全部完成"判断在"每个 Unit 都有代码落地"这个意义上是对的。但"全部完成"不等于"和文档设定的目标完全对齐"——
  9 +差距集中在 PH5 的"自动"两个字还没完全兑现、系统还不是真正自驱动的、以及若干 SDD 契约的已知简化。逐条见下。
  10 +**如何维护本文档**: 这是一次性快照,会随实现推进而过时。发现某条已经补上或又出现新缺口时,直接编辑对应条目
  11 +(改状态、补日期),不要另开一份新文件——避免重蹈 `development-plan.md`/`unit_matrix.json`/`build-report.md`
  12 +互相之间已经出现的"哪份是最新的"混乱(见第 4 节)。
  13 +
  14 +---
  15 +
  16 +## 1. 目标层面:PH5 的"自动"闭环从未真正走完
  17 +
  18 +- REQ-15(Deploy Supervisor 蓝绿部署 + 自动回滚)的 happy path 只验证到**真实 cutover**,从没有一次交互式压测
  19 + 真正等到 `finalized`——生产参数是 24 小时或 10 个真实任务的监控窗口,本来就不该在一次交互会话里等到。
  20 +- 更关键:**Deploy Supervisor 真正"自动"的那部分——持续轮询"有没有新的 self-target 合并等待部署"——从未
  21 + 实现**。`supervisor/deploy_supervisor.py`(U-036)只管进程生命周期(start/stop/status/PID),`poll_fn` 是留
  22 + 给未来的钩子;`supervisor/self_hosting_e2e.py`(U-042)只是验收脚本的一次性编排(人工触发一条 Goal→...→
  23 + cutover 的直线调用),不是常驻发现循环。PH5 的目标"自主修改自己代码后自动安全部署",目前是"能做到"而不是
  24 + "会自己做"。
  25 +- 回滚路径 B(监控期异常检测触发)只在 mock 测试里验证过分支逻辑,从未在真实生产数据下真正触发过。
  26 +- **(2026-08-03 自举可行性分析中新发现)部署缺互斥锁**:`supervisor/deploy_stage.stage_deployment()` 只检查
  27 + `_last_finalized_deployment()`(上一次成功落地的部署是什么),**不检查当前是否已有一个进行中
  28 + (`staged`/`candidate_starting`/…未到终态)的部署**。如果用调度器并发派发多个 self-target 任务、且多个都
  29 + 想 cutover,会互相踩——这不是原来清单里的条目,是分析"能否用调度器批量跑 gap 修复"时才暴露出来的新缺口,
  30 + 建议在真正启用并发自举之前先补上。
  31 +
  32 +## 2. "熄灯"叙事本身:没有常驻调度入口
  33 +
  34 +`src/scheduler.py`(U-032)从未被 `backend_server.py` 或任何生产入口调用——`backend_server.py` 里只有一句注
  35 +释提到"调度器以后会长在这里"(grep 确认,无实际调用)。系统目前不存在一个真正常驻、自动扫描 `queued` 任务
  36 +并派发的进程;每一次"跑起来"都是脚本/测试手动调用 `run_scheduler_once`/`run_goal_to_completion`。这和"Level
  37 +5 自主研发系统"的叙事有实质差距——机制都齐了,但缺"自己跑起来"这一层。
  38 +
  39 +## 3. SDD 契约与实现之间已知的简化/偏差
  40 +
  41 +- **Skill 内容从未真正迁移**:`.claude/agents/*.md` 的 `skills:` frontmatter 只是引用名字(如
  42 + `code-task-development`),仓库里没有 `skills/<skill_id>/` 真实文件,测试靠 seed 占位内容绕过——
  43 + `src/skill_update.py` 自己的注释也承认这点。
  44 +- **Coder 阶段没有真正的多模块 Work Graph 规划**:`c_work_graph_plan` 目前只服务 Planner 的固定四节点线性图
  45 + (brd→prd→sdd→benchmark);Coder 永远走一个合成的单节点 manifest,"规划一个真正的多文件代码变更图"这个能
  46 + 力从未被要求也没实现。
  47 +- **✅ 已修复(2026-08-03)普通 target project 从未部署过 Agent 定义**:
  48 + `agent_profile.deploy_agent_definitions()` 存在,但没有任何生产代码路径调用它——`project_scaffold.create_project()`
  49 + 建 project 时不会调,`org_node_trigger.py` 自己的注释也如实写着"this function does not deploy it"。self-target
  50 + 场景"恰好"没事,是因为 `.claude/agents/*.md` 本来就是 graphlemon 自己仓库里已提交的文件;但任何**新建的普通
  51 + target project**,第一次触发 Planner 时 `claude --agent planner` 会因为 cwd 下找不到 agent 定义而非零退出,
  52 + 抛出 `OrgNodeTriggerError`——这个异常不在 `scheduler._ROUTABLE_EXCEPTIONS` 里,被兜底成笼统的
  53 + `blocked_reason='execution_error'`,真实报错内容不会被持久化,控制台上完全看不出原因。真实复现:项目
  54 + `LemonGraphFront`(`target_folder_path=.../graphlemon/frontend/`)的任务从 `queued` 直接跳到
  55 + `blocked_pending_human`,零 artifact 产出。
  56 + **修复**:`project_scaffold.create_project()` 现在会在 `.graphlemon/` 初始化提交时一并调用
  57 + `agent_profile.deploy_agent_definitions()` 并 commit(同一个 commit,没有多引入一次提交,也顺带解决了下面
  58 + worktree 隔离那条 gap);`scheduler._block_task()`/`task_execution._mark_blocked()` 新增 `detail` 参数,把真实
  59 + 异常信息写进 `execution_event.payload`,不再是不可诊断的裸字符串(`blocked_reason` 本身值不变,
  60 + `budget_guardrail` 等地方靠精确匹配它的值,没有改)。新增/扩展了 3 处回归测试
  61 + (`test_project_scaffold.py`/`test_scheduler.py`/`test_task_execution.py`),`src/` 全量 498 个测试 + `supervisor/`
  62 + 123 个测试全绿。**真实验证**:给 `LemonGraphFront` 项目手动补部署+提交 agent 定义后,`task_id=7400d876-…` 走完
  63 + 了完整闭环——BRD/PRD/SDD/Benchmark/code_diff/test_report 六个 artifact 全部 `validation_status=passed`,中途
  64 + Validator 第一轮 FAIL 还真实触发了一次 Org-level Loop 的 `full_replan` 路由、重新规划后第二轮通过,最终
  65 + `task.status=done`。是通过这次交互式会话直接改代码修的,**不是**走 graphlemon 自己的 Planner→Coder→Validator
  66 + 自举出来的——对应第 6 节任务清单顺序 1(原描述是"补 commit",实际根因和修复范围比原描述更大,一并记在这里)。
  67 +- **普通 target project 的并行 worktree 隔离没被真正踩过**:`needs_worktree` 目前只有 self-target 场景被
  68 + `work_graph_orchestrate.py` 强制打开;给普通项目手动开启时,原先 `agent_profile.deploy_agent_definitions()` 只
  69 + 写 `.claude/agents/*.md` 文件、不 commit,而 `git worktree add` 只认已提交内容——这条路径理论上会失败。
  70 + **随上面那条一起修复**:`create_project()` 现在会把部署的 agent 定义提交,新建项目从一开始就有已提交的
  71 + `.claude/agents/*.md`,这个阻塞点已解除;但"手动对一个已存在、之前没有 agent 定义的项目开启 worktree"这种
  72 + 场景仍未专门测试过,不算完全清零。
  73 +- `v_artifact_browser`(U-028)有意不做 Markdown 渲染/代码高亮(02-V 原型期望有),因为要避免引入第一个前端
  74 + 第三方库,判断为有意简化而非漏做。
  75 +
  76 +## 4. 文档本身滞后于实现(这本身就是一种"差距")
  77 +
  78 +- `development-plan.md`/`unit_matrix.json` 从写就没有随实现更新"done"状态,只能靠 `git log` 和散落在各 Unit
  79 + 条目下的事后补记("实现补充"/"真实 bug")判断真实进度。
  80 +- `docs/build-report.md` 里"⚠️ 尚未真实执行的一项"那一段(称 `RUN_REAL_SELF_HOSTING_E2E` 从未跑过)本身也已
  81 + 经过时——`development-plan.md` U-042 条目下 2026-08-03 的记录显示之后确实做了 13 次真实自举压测(`git log`
  82 + 里那一长串 `graphlemon: auto-commit`/`merge`/artifact 提交就是证据),只是仍未到达 `finalized`。连"总结性
  83 + 收口文档"都会在自举持续运行的过程中被甩在后面——这是个会持续放大的风险,不是一次性的疏漏。
  84 +
  85 +## 5. 验证方法上的缺口
  86 +
  87 +- 几乎所有 PH3 前端 Unit(U-022~U-030)的验证方式都写着"浏览器扩展本次会话不可用",靠 `curl`/Node 原生
  88 + WebSocket/`node --check` 语法检查代替,**没有一次是真人在浏览器里点击验证过的**——功能逻辑上大概率没问
  89 + 题,但这是个反复出现、被如实记录但从未补上的验证缺口。
  90 +- `supervisor-auto-rollback-design.md` 里健康检查超时、监控窗口时长等参数明确标注"工程经验值,需要实测校
  91 + 准",还没有真实生产数据校准过(PH5 已知交叉缺口第 4 条,`development-plan.md` 第 422-427 行)。
  92 +
  93 +---
  94 +
  95 +## 6. 用 graphlemon 自举修复以上 gap 的可行性,及任务拆解清单
  96 +
  97 +**结论**:机制上可行且已被验证过(13 次真实自举压测已经走通 Planner→Coder→Validator→Org-level Loop→Deploy
  98 +Supervisor 全链路),但不能整体当成"黑灯工厂"一次性丢给它——原因就是第 2 节本身:没有常驻调度器,每一次
  99 +执行仍需人工手动触发(`run_goal_to_completion()` 或门控测试),这是当前系统的真实边界,不是这批任务的缺陷。
  100 +
  101 +下表把上述 gap 拆成可以逐个手动触发的 self-target task,标注风险等级和建议顺序。"手动触发"指人工调用一次
  102 +`scheduler.run_goal_to_completion(...)`(或未来对应的 API),不是让它们排队后无人值守跑完;每个任务完成、
  103 +Deploy Supervisor 走到 cutover 前,仍按现有习惯人工确认一次。
  104 +
  105 +| 顺序 | Gap(对应章节) | 建议 goal_text 方向 | 风险等级 | 备注 |
  106 +|---|---|---|---|---|
  107 +| 1 | ✅ 已修复(2026-08-03) 普通 target project 从未部署 Agent 定义 + worktree 隔离未提交(§3) | ~~让 `agent_profile.deploy_agent_definitions()` 在写完 `.claude/agents/*.md` 后自动 `git add`+commit~~ | 低 | 实际根因比预想的更基础(根本没调用点,不只是没 commit);**这次是人工直接改代码修的,不是走自举流程验证的**——留作顺序 2 之后再真正试一次"用 graphlemon 自己修 graphlemon"的对照 |
  108 +| 2 | 文档滞后(§4) | 让 Planner/Coder 核对 `development-plan.md`/`unit_matrix.json` 与 `git log` 的实际进度,补齐/订正"done"状态字段 | 低 | 纯文档任务,不改行为代码,出错代价最小;但要求 Coder 能准确核对而不是编造状态,建议 Validator 阶段重点复核 |
  109 +| 3 | Skill 内容未真正迁移(§3) | 把 `~/.hermes/profiles/{code,planning,qa}/skills/` 下 frontmatter 引用到的 Skill 真实内容迁移进仓库 `skills/<skill_id>/` | 中 | 已确认 `~/.hermes/skills` 在本机真实存在;风险点在于 Coder 会话对仓库外路径的读取权限没有专门验证过,需要先确认 `auto` 权限模式下这条路径可达 |
  110 +| 4 | 部署缺互斥锁(本节新发现,§1) | 给 `deploy_stage.stage_deployment()` 加"当前是否已有进行中部署"的检查,拒绝/排队重复 staging | 中 | 应该在启用并发自举(顺序 6 之后)之前完成,否则后续任务并发跑时有真实冲突风险 |
  111 +| 5 | PH3 前端从未被真人验证(§5) | 不适合作为自动化 Coder 任务——这是"人用浏览器点一遍"的验证缺口,不是代码缺陷;列在这里是为了不让它被误当成可自举任务 | 不适用 | 需要人(或 claude-in-chrome 之类的浏览器自动化)介入,不建议塞进 Planner→Coder 循环 |
  112 +| 6 | 调度器接入生产入口(§2) | 给某个常驻进程(`backend_server.py` 或独立进程)接入 `scheduler.run_scheduler_once()` 的轮询循环 | 高 | **改变系统自己的自主权边界**,建议单独一批、额外人工审查 diff,不要和其他 gap 混跑;完成并 cutover 后,理论上后续 gap 才谈得上真正黑灯 |
  113 +| 7 | Deploy Supervisor 缺 `poll_fn`(§1) | 实现"发现待部署的 self-target 合并"逻辑,接入 `supervisor/deploy_supervisor.py` 的 `poll_fn` 钩子 | 高 | 依赖顺序 6 先完成(两者共同构成"真正自驱动");且 SDD 里"什么条件触发自部署"本身有文档内部冲突(设计文档 vs 规范正文),需要人工先拍板这个设计决策,不能让 Planner 自己悄悄选一种理解 |
  114 +| — | Coder 阶段的多模块 Work Graph 规划(§3) | 不建议列入这批"修 gap"任务 | 不适用 | 这是被明确判断为"超出范围的新能力",不是缺陷;如果要做,应该走正常的新功能立项,不该包装成"修复"塞进自动循环 |
  115 +
  116 +**执行建议**:先跑 1→2(验证流程本身),再跑 3→4(补齐已知风险点),5 留给人工/浏览器自动化处理,6→7 作为
  117 +独立的、审查最严格的一批,完成后再评估是否真的要把后续维护交给自动调度。整个过程每一步仍需人工确认后触发,
  118 +不建议现在就配置成无人值守连续跑——待 6/7 完成并观察一段时间稳定后再考虑。
  119 +
  120 +**顺序 1 验证过程中额外发现的一个非代码问题,记一笔**:`org_node_lifecycle._check_concurrency()` 统计"活跃任务
  121 +数"是**跨全部 project 全局统计**的,不分项目;`/tmp/graphlemon-visual-check/graphlemon.db` 这个测试库里另一个
  122 +project 下有条历史演示任务一直停在 `coding`(非终态但没人真的在跑),加上 `org_concurrency_config` 表当时是空
  123 +的(兜底 `max_concurrent_goals=1`),导致新任务永远被并发闸门挡住,和 agent 定义那个 gap 完全无关,纯粹是这个
  124 +临时测试库里的历史数据问题(已经通过 `PUT /api/settings/concurrency` 调高上限绕过,没有改代码)。不算一个新
  125 +gap,但**全局而非按项目的并发统计**是不是有意为之,值得找个时间单独确认一下——如果以后真的多项目并行用,这
  126 +个设计会让不相关项目的任务互相卡对方的并发额度。
  127 +
  128 +---
  129 +
  130 +## 最值得关注的三条
  131 +
  132 +1. 调度器没接入生产入口,系统还不是真正自驱动的(第 2 节)。
  133 +2. Deploy Supervisor 缺"自动发现待部署改动"的 `poll_fn`,PH5 的"自动"两个字还没完全兑现(第 1 节)。
  134 +3. 文档(development-plan/unit_matrix/build-report)本身持续滞后于自举产生的新提交,这个滞后会越滚越大,
  135 + 建议找个时间点做一次文档收口,而不是继续靠事后补记维持可读性(第 4 节)。
... ...
src/project_scaffold.py
... ... @@ -14,6 +14,8 @@ import os
14 14 import subprocess
15 15 import uuid
16 16  
  17 +from src import agent_profile
  18 +
17 19 _GIT_IDENTITY = [
18 20 "-c", "user.name=graphlemon",
19 21 "-c", "user.email=graphlemon@localhost",
... ... @@ -79,9 +81,17 @@ def create_project(conn, target_folder_path, name=None, init_git_if_missing=Fals
79 81 # arrives once tasks actually execute (01-M 1.5 artifact.file_path).
80 82 open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close()
81 83  
82   - _run_git(["add", ".graphlemon"], cwd=target_folder_path)
  84 + # Without this, the first task ever triggered on this project fails
  85 + # immediately: `claude --agent planner` has nothing to find in this cwd
  86 + # (org_node_trigger.py's own docstring already says the caller is
  87 + # responsible for deploying this first, but until now nothing did --
  88 + # self-target usage never noticed because graphlemon's own .claude/agents/
  89 + # is already committed as part of its own repo).
  90 + agent_profile.deploy_agent_definitions(target_folder_path)
  91 +
  92 + _run_git(["add", ".graphlemon", ".claude"], cwd=target_folder_path)
83 93 _run_git(
84   - _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace"],
  94 + _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace + deploy agent definitions"],
85 95 cwd=target_folder_path,
86 96 )
87 97  
... ...
src/scheduler.py
... ... @@ -95,8 +95,19 @@ def _blocked_reason_for(exc):
95 95 raise AssertionError("no blocked_reason registered for " + type(exc).__name__)
96 96  
97 97  
98   -def _block_task(conn, task_id, blocked_reason):
  98 +def _block_task(conn, task_id, blocked_reason, detail=None):
  99 + """`detail` (2026-08-03): the real exception message, when there is one --
  100 + `blocked_reason` itself stays a short routing code (budget_guardrail's
  101 + auto-resume query matches on its exact value, e.g. 'budget_exceeded'), so
  102 + this doesn't touch it. Without `detail` an unroutable failure (anything
  103 + not in `_ROUTABLE_EXCEPTIONS`) landed as a bare 'execution_error' with
  104 + zero diagnostic content anywhere -- found for real via the console's
  105 + manual Start button on a freshly created project (see
  106 + project_scaffold.py's agent-definitions fix, same incident)."""
99 107 task_row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
  108 + payload = {"from_status": task_row[1], "to_status": "blocked_pending_human"}
  109 + if detail:
  110 + payload["detail"] = detail
100 111 conn.execute(
101 112 "UPDATE task SET status = 'blocked_pending_human', blocked_reason = ?, "
102 113 "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
... ... @@ -105,7 +116,7 @@ def _block_task(conn, task_id, blocked_reason):
105 116 conn.execute(
106 117 "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
107 118 "VALUES (?, ?, ?, 'task_status_changed', ?)",
108   - (str(uuid.uuid4()), task_row[0], task_id, json.dumps({"from_status": task_row[1], "to_status": "blocked_pending_human"})),
  119 + (str(uuid.uuid4()), task_row[0], task_id, json.dumps(payload)),
109 120 )
110 121 conn.commit()
111 122  
... ... @@ -181,7 +192,7 @@ def run_goal_to_completion(db_path, target_folder_path, goal_text):
181 192 result = e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text)
182 193 except _ROUTABLE_EXCEPTIONS as e:
183 194 blocked_reason = _blocked_reason_for(e)
184   - _block_task(conn, task_id, blocked_reason)
  195 + _block_task(conn, task_id, blocked_reason, detail=str(e))
185 196 return {"task_id": task_id, "final_status": "blocked_pending_human", "blocked_reason": blocked_reason}
186 197 return finish_task_lifecycle(conn, result, target_folder_path, goal_text)
187 198 finally:
... ...
src/task_execution.py
... ... @@ -114,7 +114,7 @@ def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
114 114  
115 115 def _mark_blocked(conn, task_id, exc):
116 116 reason = scheduler._blocked_reason_for(exc) if isinstance(exc, scheduler._ROUTABLE_EXCEPTIONS) else "execution_error"
117   - scheduler._block_task(conn, task_id, reason)
  117 + scheduler._block_task(conn, task_id, reason, detail=str(exc))
118 118  
119 119  
120 120 def start_task_execution_in_background(db_path, task_id, target_folder_path, goal_text):
... ...
src/test_project_scaffold.py
... ... @@ -10,7 +10,7 @@ import subprocess
10 10 import tempfile
11 11 import unittest
12 12  
13   -from src import project_scaffold, schema
  13 +from src import agent_profile, project_scaffold, schema
14 14  
15 15  
16 16 def _init_git_repo(path):
... ... @@ -70,6 +70,25 @@ class HappyPathTest(ProjectScaffoldTestBase):
70 70 ).fetchone()
71 71 self.assertEqual(row[0], "Snake Game")
72 72  
  73 + def test_agent_definitions_deployed_and_committed(self):
  74 + # Real incident (2026-08-03): without this, the first task ever
  75 + # triggered on a freshly created project fails immediately --
  76 + # `claude --agent planner` has nothing to find in this cwd. Only
  77 + # self-target usage was ever exercised, where .claude/agents/ is
  78 + # already committed as part of graphlemon's own repo, so this gap
  79 + # went unnoticed until a real non-self-target project hit it.
  80 + _init_git_repo(self.tmpdir)
  81 + project_scaffold.create_project(self.conn, self.tmpdir)
  82 +
  83 + for org_node in agent_profile.ORG_NODES:
  84 + agent_path = os.path.join(self.tmpdir, ".claude", "agents", org_node + ".md")
  85 + self.assertTrue(os.path.isfile(agent_path), agent_path)
  86 +
  87 + status = subprocess.run(
  88 + ["git", "status", "--porcelain"], cwd=self.tmpdir, check=True, capture_output=True, text=True
  89 + )
  90 + self.assertEqual(status.stdout, "", "agent definitions must be committed, not left untracked")
  91 +
73 92  
74 93 class NonGitRepoConfirmationFlowTest(ProjectScaffoldTestBase):
75 94 """02-V §2.1 / 03-C §3.0: never silently `git init`."""
... ...
src/test_scheduler.py
... ... @@ -10,6 +10,7 @@ multi-Goal coverage (RUN_REAL_AGENT_E2E=1) is in src/test_scheduler_e2e.py.
10 10 Run: python3 -m unittest src.test_scheduler -v
11 11 """
12 12  
  13 +import json
13 14 import os
14 15 import tempfile
15 16 import threading
... ... @@ -307,6 +308,21 @@ class CorruptPersistedStateRoutingTest(RoutableExceptionTestBase):
307 308 self.assertEqual(result["blocked_reason"], "corrupt_persisted_state")
308 309 self._assert_task_blocked(result["task_id"], "corrupt_persisted_state")
309 310  
  311 + def test_real_exception_message_is_persisted_for_diagnosis(self):
  312 + # 2026-08-03: a blocked task used to carry only a short routing code
  313 + # (e.g. 'execution_error') with the real failure reason nowhere to
  314 + # be found -- found via a real console Start-button failure that
  315 + # left no trace of *why*. _block_task now also stores the real
  316 + # exception message in the execution_event payload.
  317 + result = self._run_with_raising_lifecycle(work_node_run.CorruptPersistedStateError("disk full, boom"))
  318 + conn = schema.connect(self.db_path)
  319 + payload = conn.execute(
  320 + "SELECT payload FROM execution_event WHERE task_id = ? ORDER BY rowid DESC LIMIT 1",
  321 + (result["task_id"],),
  322 + ).fetchone()[0]
  323 + conn.close()
  324 + self.assertEqual(json.loads(payload)["detail"], "disk full, boom")
  325 +
310 326  
311 327 class ClassifierBlockedRoutingTest(RoutableExceptionTestBase):
312 328 def test_routes_to_blocked_pending_human(self):
... ...
src/test_task_execution.py
... ... @@ -6,6 +6,7 @@ pause wiring, not e2e_pipeline&#39;s phase mechanics again.
6 6 Run: python3 -m unittest src.test_task_execution -v
7 7 """
8 8  
  9 +import json
9 10 import os
10 11 import subprocess
11 12 import tempfile
... ... @@ -290,8 +291,14 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
290 291  
291 292 conn = schema.connect(self.db_path)
292 293 row = conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
  294 + payload = conn.execute(
  295 + "SELECT payload FROM execution_event WHERE task_id = 'task-1' ORDER BY rowid DESC LIMIT 1"
  296 + ).fetchone()[0]
293 297 conn.close()
294 298 self.assertEqual(row, ("blocked_pending_human", "execution_error"))
  299 + # 2026-08-03: 'execution_error' alone gives zero diagnostic signal --
  300 + # the real exception message must still be recoverable somewhere.
  301 + self.assertEqual(json.loads(payload)["detail"], "boom")
295 302  
296 303  
297 304 if __name__ == "__main__":
... ...