Commit 452eb17c5e1e7e4c218acdcf064869f0b3939083
1 parent
7b803901
graphlemon: code_diff artifact for task fe8e1b3d-dea1-4fa1-aa53-8cdd77d977f3
Showing
1 changed file
with
342 additions
and
0 deletions
.graphlemon/runs/fe8e1b3d-dea1-4fa1-aa53-8cdd77d977f3/code_diff.patch
0 → 100644
| 1 | +diff --git a/docs/reference/graph-engineering-1000-agents-5-steps.diff-with-graphlemon.md b/docs/reference/graph-engineering-1000-agents-5-steps.diff-with-graphlemon.md | ||
| 2 | +new file mode 100644 | ||
| 3 | +index 0000000..dfe4954 | ||
| 4 | +--- /dev/null | ||
| 5 | ++++ b/docs/reference/graph-engineering-1000-agents-5-steps.diff-with-graphlemon.md | ||
| 6 | +@@ -0,0 +1,50 @@ | ||
| 7 | ++# 对照笔记:《Graph Engineering:一个提示词,跑起 1000+ 的智能体循环》vs graphlemon 架构 | ||
| 8 | ++ | ||
| 9 | ++- **对照对象**:[graph-engineering-1000-agents-5-steps.md](./graph-engineering-1000-agents-5-steps.md)(本文简称"5 步版") | ||
| 10 | ++- **背景**:仓库里已有同主题的 [14 步版](../Graph%20Engineering%20with%20Claude_%2014-Step%20roadmap%20from%200%20to%20graph%20architect%20(Full%20Course).md),`docs/graphlemon_architecture.md` 第 10 节已对 14 步版做过逐条映射,并在 10.1 节据此决策"Work Graph 执行层直接复用 Claude Code 原生动态工作流"。5 步版是同一方法论的精简版,本笔记只记录 5 步版带来的**新增信息**和**尚待跟进的开放项**,不重复 14 步版已覆盖的内容。 | ||
| 11 | ++- **状态**:讨论草稿,待后续会话继续展开,非最终结论。 | ||
| 12 | ++- **整理日期**:2026-08-04 | ||
| 13 | ++ | ||
| 14 | ++--- | ||
| 15 | ++ | ||
| 16 | ++## 1. 已经吻合、且已在代码里落地的部分 | ||
| 17 | ++ | ||
| 18 | ++| 5 步版观点 | graphlemon 现状 | 依据 | | ||
| 19 | ++|---|---|---| | ||
| 20 | ++| 验证器(Verifier)必须用**干净的独立上下文**,不能复用执行器的对话,否则"验证"只是自己给自己点头 | Validator 是独立的 Org Agent,独立 `claude -p` 调用,不共享 Coder 的会话上下文 | `docs/graphlemon_architecture.md` §6.3 根因溯源节点设计;`.claude/agents/validator.md` | | ||
| 21 | ++| 并行智能体必须用 **git worktree** 隔离工作区,否则会互相踩(文中举 Bun 团队真实翻车案例) | 并行 Coder 节点强制走独立 worktree,self-target(自举)场景无条件强制 | `src/work_node_run.py::create_worktree` / `ensure_worktree_committed`;CLAUDE.md「Isolation」小节 | | ||
| 22 | ++| 图需要**锚点(Anchors)**:真跑过的测试、基于证据的判定、不可被智能体自行调整的冻结规则 | Validator 只出 PASS / FAIL / INCONCLUSIVE 三态,且要求独立复核证据,不采信 Coder 自称"测试通过" | `docs/graphlemon_architecture.md` §9.1(qa profile 映射) | | ||
| 23 | ++| Work Graph 层直接用 Claude Code 原生 `agent()`/`parallel()`/`pipeline()`,编排本身零 token | 已决策并做过实测验证 | `docs/graphlemon_architecture.md` §10.1;`spikes/a1-dynamic-workflows/` | | ||
| 24 | ++ | ||
| 25 | ++## 2. 5 步版提到、但 graphlemon 仍是"已知简化项"的部分 | ||
| 26 | ++ | ||
| 27 | ++这几条是本轮对照读出来的**待办候选**,还没有决定要不要做、什么时候做: | ||
| 28 | ++ | ||
| 29 | ++1. **loop-until-dry(连续 K 轮无新发现才停止)** | ||
| 30 | ++ 现状:Org-level Loop 固定 3 轮上限,超过未收敛直接转 `blocked_pending_human`。 | ||
| 31 | ++ 出处:`docs/sdd/04-P-流程与状态流转.md`(明确标注为"未来优化候选");`docs/sdd/06-验收与测试锚点.md`。 | ||
| 32 | ++ 5 步版的场景(第 4 步"未知规模的发现")建议连续两轮无新发现才停,比固定轮次更适合"扫描规模未知"的任务——这跟 Validator 未来如果要承担"未知规模缺陷扫描"类任务是同一个开放问题。 | ||
| 33 | ++ | ||
| 34 | ++2. **按节点分层选模型**(便宜模型跑重复/有界节点,贵模型做综合裁决) | ||
| 35 | ++ 现状:`src/agent_profile.py` 里 Planner / Coder / Validator 三个 Org Agent 目前**全部固定** `claude-sonnet-5`,Work Graph 内部节点也没有差异化选型。 | ||
| 36 | ++ 出处:`docs/graphlemon_architecture.md` §10 表格第 12 行早就标注"应补进第 7.5 节预算护栏",至今未落地。 | ||
| 37 | ++ | ||
| 38 | ++3. **1000 智能体 / 16 并发的量级验证** | ||
| 39 | ++ graphlemon 目前的 Work Graph 场景(自举开发自己)是个位数到十几个节点的规模,从未真正测过大规模 fan-out。5 步版给的"先 20 个探路,看单次运行花多少钱再放开"的做法,如果以后要做"审计整个代码库"这类任务,可以直接借用作为验证节奏。 | ||
| 40 | ++ | ||
| 41 | ++## 3. 一处架构定位上的差异(不是缺口,是设计选择) | ||
| 42 | ++ | ||
| 43 | ++5 步版第 5 步专门讲"什么时候不该用 Graph"(任务太小/太孤立、需要人紧盯每一步、还在探索阶段、步骤间真正强依赖 → 别上图,用一个 Loop 就够)。 | ||
| 44 | ++ | ||
| 45 | ++这个判断题在 graphlemon 里**不是留给每次任务临场判断的**,而是直接被架构的两层设计固化成了硬边界: | ||
| 46 | ++ | ||
| 47 | ++- **Org Graph**(Planner → Coder → Validator)人工固定设计,永远不是"动态生成的图"——恰好对应 5 步版说的"步骤间真的互相依赖 + 需要锚点 + 不该让智能体自己改架构"这一类场景。 | ||
| 48 | ++- **Work Graph** 才是 5 步版描述的那种"动态生成、可扇形展开"的图,且作用域被限定在单次 Org Agent 调用内部(Planner 一次规划、Coder 一次实现、Validator 一次验收)。 | ||
| 49 | ++ | ||
| 50 | ++也就是说,graphlemon 把"要不要上图"做成了架构里的一条硬边界,而不是运行时的判断逻辑——这一点比 5 步版单纯给"判断标准"更进一步,值得在后续讨论里明确写进架构文档,作为对 5 步版方法论的一个延伸。 | ||
| 51 | ++ | ||
| 52 | ++## 4. 待讨论的开放问题(供下一轮会话展开) | ||
| 53 | ++ | ||
| 54 | ++- [ ] loop-until-dry 是否要在 Validator 的"未知规模缺陷扫描"场景里落地?优先级几何? | ||
| 55 | ++- [ ] 按节点分层选模型:Org Graph 三节点要不要差异化?Work Graph 内部节点(Planning Skill 生成的编排脚本)要不要在 Prompt 里显式要求按任务复杂度选模型? | ||
| 56 | ++- [ ] 是否需要在 `docs/graphlemon_architecture.md` 里补一节,显式写明"Org Graph 固定 / Work Graph 动态"这条边界与 5 步版"何时不该用 Graph"判断标准的对应关系? | ||
| 57 | +diff --git a/src/backend_server.py b/src/backend_server.py | ||
| 58 | +index 8716596..2b442d4 100644 | ||
| 59 | +--- a/src/backend_server.py | ||
| 60 | ++++ b/src/backend_server.py | ||
| 61 | +@@ -821,8 +821,19 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 62 | + self._send_json(200, result) | ||
| 63 | + | ||
| 64 | + def _send_file_download(self, file_path, node_exec_id): | ||
| 65 | +- with open(file_path, "rb") as f: | ||
| 66 | +- body = f.read() | ||
| 67 | ++ # execution_log_file rows are created up front (work_node_run.py) but | ||
| 68 | ++ # the backing file itself is only ever written by execution_log. | ||
| 69 | ++ # append_log() -- a node that errored before its first log write (or | ||
| 70 | ++ # a file removed out-of-band) leaves a row with no file on disk yet; | ||
| 71 | ++ # that's a clean 404, not an uncaught FileNotFoundError bubbling up | ||
| 72 | ++ # as a bare 500 (bug A2, same root cause as A1: see execution_log.py | ||
| 73 | ++ # and work_graph_orchestrate.py's DU-01 notes). | ||
| 74 | ++ try: | ||
| 75 | ++ with open(file_path, "rb") as f: | ||
| 76 | ++ body = f.read() | ||
| 77 | ++ except FileNotFoundError: | ||
| 78 | ++ self._send_json(404, {"error": "LOG_FILE_MISSING"}) | ||
| 79 | ++ return | ||
| 80 | + self.send_response(200) | ||
| 81 | + self.send_header("Content-Type", "application/octet-stream") | ||
| 82 | + self.send_header("Content-Disposition", 'attachment; filename="' + node_exec_id + '.log"') | ||
| 83 | +diff --git a/src/test_backend_server.py b/src/test_backend_server.py | ||
| 84 | +index daad261..c156be1 100644 | ||
| 85 | +--- a/src/test_backend_server.py | ||
| 86 | ++++ b/src/test_backend_server.py | ||
| 87 | +@@ -1145,6 +1145,19 @@ class ExecutionLogRouteTest(unittest.TestCase): | ||
| 88 | + self.assertEqual(status, 404) | ||
| 89 | + self.assertEqual(json.loads(body)["error"], "LOG_NOT_FOUND") | ||
| 90 | + | ||
| 91 | ++ def test_download_when_backing_file_missing_returns_clean_404_not_uncaught_exception(self): | ||
| 92 | ++ # Bug A2: execution_log_file row exists (inserted in setUp) but its | ||
| 93 | ++ # file_path was never actually written to disk -- append_log() is | ||
| 94 | ++ # what creates the file, and this test deliberately never calls it, | ||
| 95 | ++ # matching the exact real-world state before DU-01's fix (a node | ||
| 96 | ++ # that errored before its first log write, or any other row whose | ||
| 97 | ++ # backing file went missing). _send_file_download() must catch this | ||
| 98 | ++ # itself, not let a bare FileNotFoundError escape as an uncaught 500. | ||
| 99 | ++ self.assertFalse(os.path.exists(os.path.join(self.tmp.name, "node-1.jsonl"))) | ||
| 100 | ++ status, body, _ = self._get("/api/work-nodes/node-1/log?download=true") | ||
| 101 | ++ self.assertEqual(status, 404) | ||
| 102 | ++ self.assertEqual(json.loads(body)["error"], "LOG_FILE_MISSING") | ||
| 103 | ++ | ||
| 104 | + def test_purged_log_returns_410(self): | ||
| 105 | + with self.server.conn_lock: | ||
| 106 | + self.server.conn.execute("UPDATE execution_log_file SET status = 'purged' WHERE log_file_id = 'log-1'") | ||
| 107 | +diff --git a/src/test_work_graph_orchestrate.py b/src/test_work_graph_orchestrate.py | ||
| 108 | +index 31c724a..a9c4ceb 100644 | ||
| 109 | +--- a/src/test_work_graph_orchestrate.py | ||
| 110 | ++++ b/src/test_work_graph_orchestrate.py | ||
| 111 | +@@ -7,10 +7,13 @@ spike -- this module's own job is orchestration, not re-proving that). | ||
| 112 | + Run: python3 -m unittest src.test_work_graph_orchestrate -v | ||
| 113 | + """ | ||
| 114 | + | ||
| 115 | ++import json | ||
| 116 | ++import os | ||
| 117 | ++import tempfile | ||
| 118 | + import unittest | ||
| 119 | + from unittest.mock import patch | ||
| 120 | + | ||
| 121 | +-from src import paths, schema, work_graph_execute, work_graph_orchestrate as wgo, work_node_run | ||
| 122 | ++from src import execution_log, paths, schema, work_graph_execute, work_graph_orchestrate as wgo, work_node_run | ||
| 123 | + | ||
| 124 | + | ||
| 125 | + class TopologicalOrderTest(unittest.TestCase): | ||
| 126 | +@@ -64,6 +67,17 @@ class TopologicalOrderTest(unittest.TestCase): | ||
| 127 | + | ||
| 128 | + class RunGraphTestBase(unittest.TestCase): | ||
| 129 | + def setUp(self): | ||
| 130 | ++ # DU-01: run_fn now really calls execution_log.append_log(), which | ||
| 131 | ++ # writes to paths.log_file_path() (default ~/.graphlemon/logs/...) -- | ||
| 132 | ++ # isolate that under a throwaway tempdir so real (non-mocked) | ||
| 133 | ++ # run_work_node/run_fn calls in this test module never touch the | ||
| 134 | ++ # developer's actual home directory, same pattern as test_paths.py. | ||
| 135 | ++ self.home_tmp = tempfile.TemporaryDirectory() | ||
| 136 | ++ self.addCleanup(self.home_tmp.cleanup) | ||
| 137 | ++ self.old_graphlemon_home = os.environ.get("GRAPHLEMON_HOME") | ||
| 138 | ++ os.environ["GRAPHLEMON_HOME"] = self.home_tmp.name | ||
| 139 | ++ self.addCleanup(self._restore_graphlemon_home) | ||
| 140 | ++ | ||
| 141 | + self.conn = schema.connect(":memory:") | ||
| 142 | + schema.create_schema(self.conn) | ||
| 143 | + self.conn.execute( | ||
| 144 | +@@ -80,6 +94,12 @@ class RunGraphTestBase(unittest.TestCase): | ||
| 145 | + ) | ||
| 146 | + self.conn.commit() | ||
| 147 | + | ||
| 148 | ++ def _restore_graphlemon_home(self): | ||
| 149 | ++ if self.old_graphlemon_home is None: | ||
| 150 | ++ os.environ.pop("GRAPHLEMON_HOME", None) | ||
| 151 | ++ else: | ||
| 152 | ++ os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home | ||
| 153 | ++ | ||
| 154 | + def tearDown(self): | ||
| 155 | + self.conn.close() | ||
| 156 | + | ||
| 157 | +@@ -306,5 +326,83 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): | ||
| 158 | + self.assertTrue(mock_run.call_args.kwargs["needs_worktree"]) | ||
| 159 | + | ||
| 160 | + | ||
| 161 | ++class RunFnLogsExecutionEventsTest(RunGraphTestBase): | ||
| 162 | ++ """DU-01 (bug A1/A2 root cause): `_make_run_fn()`'s `run_fn` closure must | ||
| 163 | ++ actually call `execution_log.append_log()` around the real | ||
| 164 | ++ `execute_work_graph()` call, so `execution_log_file`'s backing file on | ||
| 165 | ++ disk genuinely has content -- not just a DB row tracking its lifecycle.""" | ||
| 166 | ++ | ||
| 167 | ++ def _node_exec_id(self, node_id="brd"): | ||
| 168 | ++ return self.conn.execute( | ||
| 169 | ++ "SELECT node_exec_id FROM work_node_execution WHERE run_id = 'run-1' AND node_id = ?", (node_id,) | ||
| 170 | ++ ).fetchone()[0] | ||
| 171 | ++ | ||
| 172 | ++ def _log_lines(self, node_exec_id): | ||
| 173 | ++ content = execution_log.get_log(self.conn, node_exec_id)["content"] | ||
| 174 | ++ return [json.loads(line) for line in content.splitlines() if line] | ||
| 175 | ++ | ||
| 176 | ++ def test_success_writes_node_start_then_node_completed(self): | ||
| 177 | ++ manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | ||
| 178 | ++ with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 179 | ++ patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "brd text"}): | ||
| 180 | ++ wgo.run_graph( | ||
| 181 | ++ self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 182 | ++ lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | ||
| 183 | ++ ) | ||
| 184 | ++ | ||
| 185 | ++ events = self._log_lines(self._node_exec_id()) | ||
| 186 | ++ self.assertEqual([e["event"] for e in events], ["node_start", "node_completed"]) | ||
| 187 | ++ self.assertEqual(events[1]["output"], {"content": "brd text"}) | ||
| 188 | ++ | ||
| 189 | ++ # also exercise the real HTTP-facing read path end to end (not just | ||
| 190 | ++ # the module function) -- an empty file was exactly bug A1. | ||
| 191 | ++ row = self.conn.execute( | ||
| 192 | ++ "SELECT size_bytes FROM execution_log_file WHERE node_exec_id = ?", (self._node_exec_id(),) | ||
| 193 | ++ ).fetchone() | ||
| 194 | ++ self.assertGreater(row[0], 0) | ||
| 195 | ++ | ||
| 196 | ++ def test_classifier_blocked_writes_node_failed_and_still_propagates(self): | ||
| 197 | ++ manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | ||
| 198 | ++ with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 199 | ++ patch.object( | ||
| 200 | ++ work_graph_execute, "execute_work_graph", | ||
| 201 | ++ side_effect=work_graph_execute.ClassifierBlockedError("blocked by classifier"), | ||
| 202 | ++ ): | ||
| 203 | ++ with self.assertRaises(work_graph_execute.ClassifierBlockedError): | ||
| 204 | ++ wgo.run_graph( | ||
| 205 | ++ self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 206 | ++ lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | ||
| 207 | ++ ) | ||
| 208 | ++ | ||
| 209 | ++ events = self._log_lines(self._node_exec_id()) | ||
| 210 | ++ self.assertEqual([e["event"] for e in events], ["node_start", "node_failed"]) | ||
| 211 | ++ self.assertIn("blocked by classifier", events[1]["error"]) | ||
| 212 | ++ | ||
| 213 | ++ def test_usage_limit_writes_quota_limit_then_retries(self): | ||
| 214 | ++ manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | ||
| 215 | ++ attempts = {"n": 0} | ||
| 216 | ++ | ||
| 217 | ++ def fake_execute(cwd, name, args, output_schema, timeout=600): | ||
| 218 | ++ attempts["n"] += 1 | ||
| 219 | ++ if attempts["n"] == 1: | ||
| 220 | ++ raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session") | ||
| 221 | ++ return {"content": "recovered"} | ||
| 222 | ++ | ||
| 223 | ++ with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 224 | ++ patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | ||
| 225 | ++ outputs = wgo.run_graph( | ||
| 226 | ++ self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 227 | ++ lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | ||
| 228 | ++ sleep=lambda s: None, now=lambda: 0.0, | ||
| 229 | ++ ) | ||
| 230 | ++ self.assertEqual(outputs["brd"], {"content": "recovered"}) | ||
| 231 | ++ | ||
| 232 | ++ events = self._log_lines(self._node_exec_id()) | ||
| 233 | ++ self.assertEqual( | ||
| 234 | ++ [e["event"] for e in events], ["node_start", "quota_limit", "node_start", "node_completed"] | ||
| 235 | ++ ) | ||
| 236 | ++ self.assertEqual(events[1]["quota_limit_type"], "session") | ||
| 237 | ++ | ||
| 238 | ++ | ||
| 239 | + if __name__ == "__main__": | ||
| 240 | + unittest.main() | ||
| 241 | +diff --git a/src/work_graph_orchestrate.py b/src/work_graph_orchestrate.py | ||
| 242 | +index fb0b95b..d2562f5 100644 | ||
| 243 | +--- a/src/work_graph_orchestrate.py | ||
| 244 | ++++ b/src/work_graph_orchestrate.py | ||
| 245 | +@@ -39,13 +39,42 @@ subgraph IS just itself), escalate to `work_graph_run.status='blocked'` + | ||
| 246 | + raise if the regenerated content still fails. Not a general multi-node | ||
| 247 | + re-planning mechanism -- that would be new, unscoped complexity 04-P itself | ||
| 248 | + doesn't describe either. | ||
| 249 | ++ | ||
| 250 | ++**DU-01 (2026-08-04, bug A1/A2 root-cause fix)**: `_make_run_fn()`'s `run_fn` | ||
| 251 | ++closure is the natural place to call `execution_log.append_log()` -- it's | ||
| 252 | ++the one spot that both has `node_exec_id` and wraps the actual blocking | ||
| 253 | ++`execute_work_graph()` call, and `work_node_run.run_work_node()` already | ||
| 254 | ++guarantees an `execution_log_file` row (status='active') exists before | ||
| 255 | ++`run_fn` is ever invoked. Before this, `append_log()` was fully implemented | ||
| 256 | ++(U-013/U-027) but never called by any production code path, so | ||
| 257 | ++`execution_log_file` rows existed with zero bytes of real content forever | ||
| 258 | ++-- the console's "暂无日志内容" was a correct rendering of genuinely empty | ||
| 259 | ++data, and downloading it crashed `backend_server._send_file_download()` | ||
| 260 | ++with an uncaught `FileNotFoundError` (also fixed here, see that function). | ||
| 261 | ++This is one blocking `agent()` call per node (03-C's own dynamic-workflow | ||
| 262 | ++design, see this module's own docstring above) with no mid-flight | ||
| 263 | ++progress to report, so logging is 4 discrete events around that one call, | ||
| 264 | ++not a real character-by-character stream: `node_start` (before the call), | ||
| 265 | ++`node_completed` (after a successful return), `node_failed` (any other | ||
| 266 | ++exception, re-raised unchanged after logging), `quota_limit` (the | ||
| 267 | ++`UsageLimitReachedError` case, logged before translating to | ||
| 268 | ++`work_node_run`'s own `_UsageLimitSignal`). A node retried by | ||
| 269 | ++`run_work_node`'s own retry loop calls `run_fn` again, appending another | ||
| 270 | ++`node_start`/... pair per attempt -- that's an accurate log of what | ||
| 271 | ++actually happened (N attempts), not a bug. | ||
| 272 | + """ | ||
| 273 | + | ||
| 274 | + import json | ||
| 275 | + import time | ||
| 276 | + import uuid | ||
| 277 | + | ||
| 278 | +-from src import paths, work_graph_execute, work_node_run | ||
| 279 | ++from src import execution_log, paths, work_graph_execute, work_node_run | ||
| 280 | ++ | ||
| 281 | ++ | ||
| 282 | ++def _log_event(conn, node_exec_id, event_type, **payload): | ||
| 283 | ++ execution_log.append_log( | ||
| 284 | ++ conn, node_exec_id, json.dumps({"event": event_type, **payload}) + "\n" | ||
| 285 | ++ ) | ||
| 286 | + | ||
| 287 | + | ||
| 288 | + class ConsistencyCheckFailedError(Exception): | ||
| 289 | +@@ -138,19 +167,28 @@ def _render_node_script(script_name, org_node, prompt, output_schema, model): | ||
| 290 | + ) | ||
| 291 | + | ||
| 292 | + | ||
| 293 | +-def _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): | ||
| 294 | ++def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): | ||
| 295 | + script_name = "graphlemon-node-" + node_exec_id.replace("-", "") | ||
| 296 | + | ||
| 297 | + def run_fn(cwd, model=None): | ||
| 298 | ++ _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model) | ||
| 299 | + script_content = _render_node_script(script_name, org_node, prompt, output_schema, model) | ||
| 300 | + work_graph_execute.write_workflow_script(cwd, script_name, script_content) | ||
| 301 | + try: | ||
| 302 | +- return work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout) | ||
| 303 | ++ output = work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout) | ||
| 304 | + except work_graph_execute.UsageLimitReachedError as e: | ||
| 305 | ++ _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type) | ||
| 306 | + work_node_run.raise_usage_limit_signal(e.quota_limit_type) | ||
| 307 | +- # ClassifierBlockedError / generic WorkGraphExecuteError propagate | ||
| 308 | +- # as-is -- run_work_node's own `except Exception` clause marks the | ||
| 309 | +- # node 'blocked' and re-raises, no special handling needed here. | ||
| 310 | ++ except Exception as e: | ||
| 311 | ++ # ClassifierBlockedError / generic WorkGraphExecuteError -- logged | ||
| 312 | ++ # then propagated as-is; run_work_node's own `except Exception` | ||
| 313 | ++ # clause marks the node 'blocked' and re-raises, no special | ||
| 314 | ++ # handling needed here beyond recording what actually happened. | ||
| 315 | ++ _log_event(conn, node_exec_id, "node_failed", error=str(e)) | ||
| 316 | ++ raise | ||
| 317 | ++ else: | ||
| 318 | ++ _log_event(conn, node_exec_id, "node_completed", output=output) | ||
| 319 | ++ return output | ||
| 320 | + | ||
| 321 | + return run_fn | ||
| 322 | + | ||
| 323 | +@@ -215,7 +253,7 @@ def run_graph( | ||
| 324 | + {"node_id": node_id}, | ||
| 325 | + ) | ||
| 326 | + prompt = build_prompt(node, outputs) | ||
| 327 | +- run_fn = _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | ||
| 328 | ++ run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | ||
| 329 | + output = work_node_run.run_work_node( | ||
| 330 | + conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, | ||
| 331 | + needs_worktree=effective_needs_worktree, repo_path=target_folder_path, | ||
| 332 | +@@ -253,7 +291,9 @@ def _verify_and_maybe_regenerate( | ||
| 333 | + prompt + "\n\nA consistency reviewer flagged issues with your previous draft: " | ||
| 334 | + + "; ".join(feedback or []) + "\nProduce a corrected version resolving these issues." | ||
| 335 | + ) | ||
| 336 | +- reflect_run_fn = _make_run_fn(target_folder_path, reflect_node_exec_id, org_node, reflect_prompt, output_schema, timeout) | ||
| 337 | ++ reflect_run_fn = _make_run_fn( | ||
| 338 | ++ conn, target_folder_path, reflect_node_exec_id, org_node, reflect_prompt, output_schema, timeout | ||
| 339 | ++ ) | ||
| 340 | + regenerated = work_node_run.run_work_node( | ||
| 341 | + conn, reflect_node_exec_id, {"node_id": node_id}, input_schema, output_schema, reflect_run_fn, | ||
| 342 | + needs_worktree=needs_worktree, repo_path=target_folder_path, |