NOTE: 依据
SOUL.md§16(迁移后的状态白名单约束),本 Agent 不直接改写task.status,仅产出本 BRD Artifact 供c_org_node_trigger判定流转;下文的证据与事实均来自对当前代码(非文档)的直接读取,读取范围:src/execution_log.py、src/work_graph_orchestrate.py、src/work_node_run.py、src/backend_server.py(含其测试src/test_backend_server.py/src/test_work_graph_orchestrate.py)、docs/sdd/06-验收与测试锚点.mdREQ-13。
BRD:打通执行日志真实写入路径,修复"暂无日志内容"与"下载崩溃"两个同根因 Bug
0. 文档信息
- 关联需求锚点:
docs/sdd/06-验收与测试锚点.mdREQ-13("原始日志控制台内查看 + 定时清理",架构文档 13.1 节决策 4) - 覆盖实体/接口(沿用 REQ-13 覆盖清单):M=
execution_log_file;V=v_log_viewer;C=c_execution_log_get - 类型:Bug 修复(单一根因,两个可观察症状),非新特性
- 风险等级:低-中(不涉及权限/支付/数据不可逆变更;但触碰
run_work_node/run_graph的核心执行闭环签名,属于"多处调用点的函数签名变更",需要回归验证) - 状态机备注:本文档为
task生命周期中planning_brd_prd_sdd阶段产出的 BRD Artifact,不代表任务已完成;状态流转由c_org_node_trigger/c_org_loop_route依据 BRD/PRD/SDD/Benchmark 判定
1. 背景与问题陈述
1.1 用户可观察的症状(事实,来自现有代码与用户报告的交叉验证)
- 症状 A1:控制台 Graph 页面中,任意一个 Work Node 视图点击后,日志区域始终显示"暂无日志内容",无论该节点实际是否已经执行完成。
- 症状 A2:点击"下载完整日志"按钮报错、页面跳转异常。
1.2 根因(已通过直接读取生产代码确认,非猜测)
两个症状是同一个根因的两种表现:日志文件从被创建到被消费的整条链路里,"写入"这一环从未被任何生产代码调用过。
具体证据:
src/execution_log.py:46的append_log(conn, node_exec_id, content)实现完整——写磁盘文件、更新execution_log_file.size_bytes、写execution_event(event_type='log_appended')供 WS 实时订阅——但该模块自己的文件头注释(src/execution_log.py:5-10)已经明确写着:"append_log()is that missing write path, meant to be called from inside a run_fn as it works",即设计时就知道这是留给调用方接入的挂钩点,从未真正接入。- 全仓库
src/*.py生产代码中搜索append_log(调用,零命中(仅execution_log.py自身定义处、以及该模块自己的单元测试直接调用)。 src/work_node_run.py:262-268确实会在每个节点开始执行时INSERT INTO execution_log_file (...) VALUES (..., 'active'),并在finally块(src/work_node_run.py:410-414)里把状态更新为'closed'——即execution_log_file行的 生命周期状态 被正确管理,但这条记录指向的磁盘文件file_path自始至终从未被open(..., "a")写入过一个字节。src/work_graph_orchestrate.py:141-155的_make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout)是真实节点执行的唯一入口(run_fn闭包内部调用work_graph_execute.execute_work_graph(...)拿到 agent 的真实输出),但其函数签名里没有conn参数,闭包体内也没有任何日志写入调用。run_graph()(work_graph_orchestrate.py:158)本身持有conn(是它的第一个形参),但从未把它继续传给_make_run_fn。- 因此:
src/execution_log.py:78的get_log()(GET /api/work-nodes/:node_exec_id/log的静态读取路径)在os.path.isfile(file_path)为False时会正确返回{"content": "", ...}(execution_log.py:90-91)——前端把这个空content渲染成"暂无日志内容"是对空数据的正确反应,不是前端 bug,无需改动frontend/app.js。 src/backend_server.py:823-831的_send_file_download(file_path, node_exec_id)直接open(file_path, "rb")(第 824 行),没有try/except FileNotFoundError。因为上述第 3 点,file_path指向的文件从未被创建,open()必然抛出未捕获的FileNotFoundError,导致该请求以裸异常/500(或连接中断)方式失败,前端表现为"报错、跳转到不存在的页面"。对照同一文件里_handle_execution_log_get(backend_server.py:802-821)已经为execution_log.LogNotFoundError/execution_log.LogPurgedError建立了"捕获特定异常 → 返回结构化 JSON 错误体"的风格(404/410),_send_file_download是这条风格链里唯一的缺口。
1.3 为什么这是"同一个根因"而不是两个独立 bug
A2 的 FileNotFoundError 之所以会被触发,前提正是 A1 描述的"日志文件从未被创建"。如果只修 A2(加 try/except),下载接口会变成"优雅地返回 404/空文件",但日志区域依然永远空白——用户的核心诉求(看到真实执行日志)完全没有被满足。反过来,如果只修 A1(接入 append_log),A2 的未捕获异常会在文件的边界场景(例如节点被判定为立即 blocked、从未真正跑到写日志那一步;或历史遗留、修复前创建的 execution_log_file 行)下依然存在。两处都要修,且顺序上 A1 是使 A2 变得"通常不会触发"的必要条件,A2 是无论如何都该有的防御性错误处理(边界/异常场景永远存在)。
2. 目标与非目标
2.1 目标(Goals,均为用户可观察结果)
- G1:任意一次真实 Work Node 执行完成后,控制台 Graph 页面点击该节点,日志区域能看到真实的、与该节点实际执行相关的日志内容(而不是"暂无日志内容")。
- G2:点击"下载完整日志",无论该节点日志文件是否存在内容,都能得到一个明确、干净的响应(有内容则下载文件;文件确实不存在则返回结构化错误,而不是裸异常/崩溃跳转)。
- G3:修复必须用真实数据验证,而不是仅靠 mock 单元测试通过——即真实跑一次 Work Graph 节点执行,确认磁盘上出现了非空日志文件、
GET /api/work-nodes/:node_exec_id/log读到非空content、下载接口可用。
2.2 非目标(Non-Goals,本次不做,明确排除以防范围蔓延)
- 不做逐字符/逐 token 的真实流式日志(用户已在原始需求中明确说明:动态工作流本身是一次阻塞调用,
execute_work_graph()内部没有中间进度可取,"具体怎么划分日志条目由 PL/Coder 判断",只需要在真实调用前后各记一条)。 - 不改动
frontend/app.js的日志显示逻辑本身——已确认是对空数据的正确反应,问题在数据源侧,不在展示侧。 - 不新增
p_log_retention_cleanup调度器守护进程(execution_log.py文件头注释已明确这是独立、未来的 Unit 范围,与本次两个 bug 无关,不得借此机会顺带实现)。 - 不重新设计
execution_log_file的表结构或状态机(active/closed/purged三态生命周期管理已经正确,src/work_node_run.py部分不需要改动)。 - 不处理
_verify_and_maybe_regenerate(reflect 重试路径)之外的其他潜在日志缺口,除非它们复用同一个_make_run_fn(见 3.3 节说明,reflect 路径也会自动受益,因为它复用同一个函数)。
3. 事实 / 设计决策 / 待验证假设 / 阻塞性未知
3.1 已知事实(有明确代码来源,见第 1 节脚注式引用)
append_log()实现完整且从未被生产代码调用(execution_log.py+ 全仓库 grep 结果)。_make_run_fn当前签名不含conn,run_fn闭包内不含任何日志写入(work_graph_orchestrate.py:141-155)。run_graph()自身持有conn(第一形参),_make_run_fn在run_graph()内被调用两处:节点首次执行(work_graph_orchestrate.py:218)与 reflect 重试路径(work_graph_orchestrate.py:256)。_send_file_download()无FileNotFoundError捕获(backend_server.py:823-831),同文件内已有LogNotFoundError/LogPurgedError的 404/410 JSON 错误响应先例可以直接复用其风格(backend_server.py:816-819)。execution_log_file的行生命周期(active→closed)由work_node_run.py正确管理,不需要改动。- REQ-13 的 happy-case 验收原文(
docs/sdd/06-验收与测试锚点.md:113)直接写着"某 Work Node 运行中,v_log_viewer能实时滚动显示新追加的日志内容"——这条验收锚点此前从未被真正满足过,本次修复是它第一次具备被满足的前提条件。
3.2 设计决策(PL 基于约束做出的选择,附理由)
- 决策 D1:
append_log()的调用点放在_make_run_fn()内部的run_fn闭包里,而不是放在更上层的run_graph()循环体里。 理由:run_fn(cwd, model)是唯一真正拿到 agent 真实输出(work_graph_execute.execute_work_graph(...)的返回值)的地方;run_graph()循环体只拿到run_work_node()的最终结果(schema 校验后、含重试逻辑),如果在那一层记日志,会丢失"这次具体是第几次 attempt、真实原始输出是什么"的粒度,也无法在 agent 调用真正开始前就记一条"开始执行"日志(run_work_node内部还有 worktree 创建、schema 校验等前置步骤,用户原始需求点名的是"真实调用前后")。这与用户需求原文的接入点选择一致。 - 决策 D2:
conn通过参数一路显式传递(run_graph()→_make_run_fn()→ 闭包内部使用),不使用全局/线程局部变量或模块级单例。 理由:与本仓库既有约定一致——run_work_node(conn, ...)、run_graph(conn, ...)均已是显式传参风格;backend_server.py用self.server.conn_lock+self.server.conn管理并发访问,说明conn的访问需要受控(加锁),显式传参能保持调用链路可读、可测试(mock 一个conn即可单测,不需要打补丁全局状态)。 - 决策 D3:日志条目的具体切分("节点开始执行"一条 + "拿到真实输出"一条,或更细)由实现者判断,不在 BRD 里锁死成唯一方案。 理由:用户原始需求已明确"具体怎么划分日志条目由你判断,不需要做成逐字符真流式"——BRD 层面只约束必须发生写入、且发生在真实调用前后这个业务结果,写入粒度是实现细节,锁死会制造不必要的约束(违反"最小、可验证"的拆分原则)。
- 决策 D4:
_send_file_download()捕获FileNotFoundError后返回 404 + 结构化 JSON body(例如{"error": "LOG_FILE_NOT_FOUND"}或复用已有的LOG_NOT_FOUND语义,由实现者对齐同文件既有错误码命名风格),而不是让请求方对着一个二进制下载响应硬解析错误。 理由:与_handle_execution_log_get里已有的 404LOG_NOT_FOUND/ 410LOG_PURGED错误处理风格保持一致(backend_server.py:816-819),复用而不是新造一套错误处理约定。
3.3 待验证假设(需要在实现/验证阶段通过实际运行确认,不能只凭读代码判断)
- H1:把
conn加入_make_run_fn的签名后,两处调用点(work_graph_orchestrate.py:218首次执行、:256reflect 重试)只需同步传参即可,不需要额外改动调用方逻辑——需要在实现后跑src/test_work_graph_orchestrate.py全量回归确认没有遗漏的第三个隐藏调用点或 mock 未同步更新的测试。 - H2:
append_log()对同一个node_exec_id被多次调用(一次"开始"、一次"结束")时,execution_log_file.size_bytes的递增更新和execution_event(log_appended)的多条写入不会与work_node_run.py的finally块(把execution_log_file.status置为'closed')产生时序冲突——append_log()本身不检查status,理论上不会冲突,但需要真实跑一次含 reflect 重试的节点(多次 append)来确认。 - H3:
_send_file_download()补上异常捕获后,Content-Length/Content-Disposition等响应头的写入时机(send_response/send_header/end_headers调用顺序,backend_server.py:826-830)在改成先try: open()之后依然保持"要么完整发送 200+文件,要么在还没调用send_response之前就返回 404"的干净二选一,不会出现"已经发了部分响应头又要发错误 JSON"的畸形响应——需要实现时注意open()失败必须发生在任何self.send_response(...)调用之前。
3.4 阻塞性未知
无。本次两个 bug 的根因、接入点、错误处理风格均已通过读代码确认,不存在需要产品/业务/外部决策才能推进的未知项,可以直接进入开发。
4. 范围(Scope)
4.1 范围内文件
src/execution_log.py:不修改其内部实现(append_log()已经完整、正确),仅作为被调用方。src/work_graph_orchestrate.py:_make_run_fn()增加conn参数;run_fn闭包内,在调用work_graph_execute.execute_work_graph(...)前后各调用一次execution_log.append_log(conn, node_exec_id, ...);run_graph()内两处_make_run_fn(...)调用点同步传入conn(run_graph()自身已持有该参数,无需改动其自身签名)。src/backend_server.py:_send_file_download()增加FileNotFoundError捕获,返回结构化 404 JSON 错误体;_handle_execution_log_get()中调用_send_file_download的位置视实现需要可能需要同步调整异常捕获层级(由实现者判断放在_send_file_download内部捕获更贴近现有LogNotFoundError/LogPurgedError的捕获风格所在层级,还是在_send_file_download内部自行 try/except 并返回)。- 对应测试文件:
src/test_work_graph_orchestrate.py、src/test_backend_server.py(mock 层面的单元测试更新,验证签名变更和错误处理,覆盖真实数据验证之外的边界/异常场景)。
4.2 明确排除
frontend/app.js(已确认无需改动,见 2.2)。src/execution_log.py内部实现、src/work_node_run.py的execution_log_file状态机管理。- 调度器/
p_log_retention_cleanup相关代码。
5. 验收标准(Definition of Done)
必须同时满足以下三类证据,缺一不可(用户已明确要求"修完后请用真实数据验证",因此本次 Done 标准比常规更严格,Mock 单测通过不足以视为完成):
5.1 单元测试证据(mock 边界,回归现有行为)
src/test_work_graph_orchestrate.py:新增/更新用例断言run_fn执行路径中,execution_log.append_log(或对其效果的可观察断言,例如断言 mock conn 上执行了预期的 SQL/断言磁盘上真实生成的日志文件内容)在真实 agent 调用前、后各被触发一次;已有全部用例(含 reflect 重试路径_verify_and_maybe_regenerate相关用例)在_make_run_fn签名变更后依然全部通过。src/test_backend_server.py:新增用例覆盖"execution_log_file行存在但磁盘文件已被外部删除/从未创建"场景下download=true请求,断言返回结构化 404 JSON(而非抛出未捕获异常/连接中断);已有test_download_returns_raw_file_with_attachment_headers(src/test_backend_server.py:1135)等既有下载用例保持通过。- 命令:
python3 -m unittest discover -s src(本仓库唯一的后端测试根,见CLAUDE.md)全量通过,不允许因本次改动引入任何新的失败或被跳过的用例。
5.2 真实数据验证证据(用户明确要求,不可用 mock 替代)
- 真实跑一次任意 Work Graph 节点执行(例如通过
e2e_pipeline.py走一遍真实闭环,或针对run_graph()单独构造一次真实调用),执行完成后:- 该节点对应的
execution_log_file.file_path磁盘文件真实存在且非空(os.path.getsize(...) > 0)。 GET /api/work-nodes/:node_exec_id/log(execution_log.get_log()路径)返回的content字段非空,且内容与该节点真实执行的信息相关(不是占位符/固定字符串)。- 下载接口(
download=true路径)能正常返回该文件的完整二进制内容,响应头Content-Disposition/Content-Length与文件实际大小一致,不报错、不跳转异常页面。
- 该节点对应的
- 这一步的执行记录(命令、输出摘要)应作为验证证据保留,供 Validator 复核(对应 SOUL.md §9 可追溯原则:"原始要求 → 主任务目标/验收 → 责任 Unit → 测试证据")。
5.3 边界/异常场景证据
execution_log_file行存在、但因为节点在append_log被调用之前就失败(例如InvalidInputError,work_node_run.py:226-227那种"校验失败直接拒绝,不进入执行"的场景)导致文件从未被创建的情况下:GET .../log(非下载)应继续返回{"content": "", ...}(execution_log.py现有行为,不应回归)。GET .../log?download=true应返回结构化 404 JSON,而不是裸异常(这正是 A2 的核心修复目标,必须显式测试,不能只靠"日志现在总是有内容了所以问题自然消失"这种侥幸覆盖)。
- 已
purged(被清理)的日志:LogPurgedError路径(410)不应被本次改动破坏。
6. 风险与依赖
- 风险 R1(签名变更影响面):
_make_run_fn增加conn参数是一个函数签名变更,影响run_graph()内两个调用点和所有依赖work_graph_orchestrate模块的测试/调用方(src/e2e_pipeline.py:226,269通过run_graph()间接调用,e2e_pipeline.py本身不直接调用_make_run_fn,风险较低,但仍需回归src/test_work_graph_orchestrate.py全量用例确认)。 - 风险 R2(并发写入):
append_log()内部会执行conn.execute+conn.commit();backend_server.py对self.server.conn的访问统一走self.server.conn_lock(如backend_server.py:807,814),但run_graph()/work_node_run.py侧目前直接使用传入的conn而不显式加这把锁——如果 Work Graph 执行与 HTTP 请求处理共享同一个conn/同一个 SQLite 连接且跨线程,需要确认现有加锁机制是否已经覆盖了这条新增的写入路径,避免引入 SQLite "database is locked"类问题。这是实现阶段需要确认的风险点,不属于阻塞性未知(现有代码库已有conn_lock先例可以参照,不是从零设计)。 - 依赖:本次改动不依赖任何未完成的其他 Unit;
execution_log.py、work_node_run.py均已是稳定、可直接复用的现成实现。
7. 可追溯性矩阵
| 原始要求 | 责任范围 | 验收标准锚点 |
|---|---|---|
| 修复 A1:日志区域显示"暂无日志内容" | work_graph_orchestrate.py 接入 append_log |
5.1(单测)+ 5.2.1/5.2.2(真实数据) |
| 修复 A2:下载报错/跳转异常页面 | backend_server.py _send_file_download 异常处理 |
5.1(单测)+ 5.2.3(真实数据)+ 5.3(边界场景) |
"conn 一路传进 _make_run_fn()" |
_make_run_fn/run_graph 签名变更 |
5.1 全量回归通过(R1) |
| "真实调用前后各记一条日志,粒度自行判断" | run_fn 闭包内部实现 |
3.2 D3 + 5.2.2(内容非占位符) |
| "不需要逐字符真流式" | 非目标声明 | 2.2 |
| "不改动 frontend/app.js" | 非目标声明 | 2.2、1.2 第 5 点事实依据 |
| "用真实数据验证" | 验证阶段 | 5.2 全部三项 |
REQ-13 happy-case(v_log_viewer 能显示追加日志) |
整体修复 | 5.2 |
8. PL 自审(Failure-Closed Checklist)
- [x] 根因是否有明确代码来源,而非猜测:是(第 1.2 节六条证据均标注了具体文件行号)。
- [x] 是否区分了事实/决策/假设/未知:是(第 3 节四类分开列出)。
- [x] 是否存在阻塞性未知:否(3.4 节明确结论)。
- [x] 范围是否清晰、是否显式排除了容易蔓延的相邻工作(流式日志、调度器、前端改动):是(第 2.2、4.2 节)。
- [x] 验收标准是否可测试、是否描述可观察结果而非内部实现细节:是(第 5 节区分单测/真实数据/边界三类,且真实数据验证是用户显式要求、不可被 mock 替代的硬性条件)。
- [x] 依赖是否显式、是否存在环:无依赖、无环(第 6 节)。
- [x] 原始要求是否都能在追溯矩阵中找到责任方和验收锚点:是(第 7 节)。
- [x] 是否需要更深调查(权限/支付/不可逆数据变更等高风险场景):否,本次改动风险等级为低-中,已识别的风险点(R1 签名影响面、R2 并发加锁)都有现成模式可参照,不需要额外调查即可进入开发;建议 Code Agent 在改动
_send_file_download时优先复用_handle_execution_log_get已有的错误响应风格,不新造一套错误码命名。
本 BRD 判定:可以流转到开发(Code Agent),无阻塞性未知,范围与验收标准明确。后续 PRD/SDD(如该流水线要求)应在此基础上补充 _make_run_fn/_send_file_download 的具体函数级签名设计,BRD 本身不预先锁定实现代码。