Commit c18e4d4112f911ffadaab68826de2f872ed46ef0

Authored by graphlemon
1 parent f16d21bf

graphlemon: auto-commit uncommitted changes for 08b89f13-2c70-4f72-b743-6c6e81702b85

src/backend_server.py
@@ -821,8 +821,19 @@ class _Handler(BaseHTTPRequestHandler): @@ -821,8 +821,19 @@ class _Handler(BaseHTTPRequestHandler):
821 self._send_json(200, result) 821 self._send_json(200, result)
822 822
823 def _send_file_download(self, file_path, node_exec_id): 823 def _send_file_download(self, file_path, node_exec_id):
824 - with open(file_path, "rb") as f:  
825 - body = f.read() 824 + # execution_log_file rows are created up front (work_node_run.py) but
  825 + # the backing file itself is only ever written by execution_log.
  826 + # append_log() -- a node that errored before its first log write (or
  827 + # a file removed out-of-band) leaves a row with no file on disk yet;
  828 + # that's a clean 404, not an uncaught FileNotFoundError bubbling up
  829 + # as a bare 500 (bug A2, same root cause as A1: see execution_log.py
  830 + # and work_graph_orchestrate.py's DU-01 notes).
  831 + try:
  832 + with open(file_path, "rb") as f:
  833 + body = f.read()
  834 + except FileNotFoundError:
  835 + self._send_json(404, {"error": "LOG_FILE_MISSING"})
  836 + return
826 self.send_response(200) 837 self.send_response(200)
827 self.send_header("Content-Type", "application/octet-stream") 838 self.send_header("Content-Type", "application/octet-stream")
828 self.send_header("Content-Disposition", 'attachment; filename="' + node_exec_id + '.log"') 839 self.send_header("Content-Disposition", 'attachment; filename="' + node_exec_id + '.log"')
src/test_backend_server.py
@@ -1145,6 +1145,19 @@ class ExecutionLogRouteTest(unittest.TestCase): @@ -1145,6 +1145,19 @@ class ExecutionLogRouteTest(unittest.TestCase):
1145 self.assertEqual(status, 404) 1145 self.assertEqual(status, 404)
1146 self.assertEqual(json.loads(body)["error"], "LOG_NOT_FOUND") 1146 self.assertEqual(json.loads(body)["error"], "LOG_NOT_FOUND")
1147 1147
  1148 + def test_download_when_backing_file_missing_returns_clean_404_not_uncaught_exception(self):
  1149 + # Bug A2: execution_log_file row exists (inserted in setUp) but its
  1150 + # file_path was never actually written to disk -- append_log() is
  1151 + # what creates the file, and this test deliberately never calls it,
  1152 + # matching the exact real-world state before DU-01's fix (a node
  1153 + # that errored before its first log write, or any other row whose
  1154 + # backing file went missing). _send_file_download() must catch this
  1155 + # itself, not let a bare FileNotFoundError escape as an uncaught 500.
  1156 + self.assertFalse(os.path.exists(os.path.join(self.tmp.name, "node-1.jsonl")))
  1157 + status, body, _ = self._get("/api/work-nodes/node-1/log?download=true")
  1158 + self.assertEqual(status, 404)
  1159 + self.assertEqual(json.loads(body)["error"], "LOG_FILE_MISSING")
  1160 +
1148 def test_purged_log_returns_410(self): 1161 def test_purged_log_returns_410(self):
1149 with self.server.conn_lock: 1162 with self.server.conn_lock:
1150 self.server.conn.execute("UPDATE execution_log_file SET status = 'purged' WHERE log_file_id = 'log-1'") 1163 self.server.conn.execute("UPDATE execution_log_file SET status = 'purged' WHERE log_file_id = 'log-1'")
src/test_work_graph_orchestrate.py
@@ -7,10 +7,13 @@ spike -- this module's own job is orchestration, not re-proving that). @@ -7,10 +7,13 @@ spike -- this module's own job is orchestration, not re-proving that).
7 Run: python3 -m unittest src.test_work_graph_orchestrate -v 7 Run: python3 -m unittest src.test_work_graph_orchestrate -v
8 """ 8 """
9 9
  10 +import json
  11 +import os
  12 +import tempfile
10 import unittest 13 import unittest
11 from unittest.mock import patch 14 from unittest.mock import patch
12 15
13 -from src import paths, schema, work_graph_execute, work_graph_orchestrate as wgo, work_node_run 16 +from src import execution_log, paths, schema, work_graph_execute, work_graph_orchestrate as wgo, work_node_run
14 17
15 18
16 class TopologicalOrderTest(unittest.TestCase): 19 class TopologicalOrderTest(unittest.TestCase):
@@ -64,6 +67,17 @@ class TopologicalOrderTest(unittest.TestCase): @@ -64,6 +67,17 @@ class TopologicalOrderTest(unittest.TestCase):
64 67
65 class RunGraphTestBase(unittest.TestCase): 68 class RunGraphTestBase(unittest.TestCase):
66 def setUp(self): 69 def setUp(self):
  70 + # DU-01: run_fn now really calls execution_log.append_log(), which
  71 + # writes to paths.log_file_path() (default ~/.graphlemon/logs/...) --
  72 + # isolate that under a throwaway tempdir so real (non-mocked)
  73 + # run_work_node/run_fn calls in this test module never touch the
  74 + # developer's actual home directory, same pattern as test_paths.py.
  75 + self.home_tmp = tempfile.TemporaryDirectory()
  76 + self.addCleanup(self.home_tmp.cleanup)
  77 + self.old_graphlemon_home = os.environ.get("GRAPHLEMON_HOME")
  78 + os.environ["GRAPHLEMON_HOME"] = self.home_tmp.name
  79 + self.addCleanup(self._restore_graphlemon_home)
  80 +
67 self.conn = schema.connect(":memory:") 81 self.conn = schema.connect(":memory:")
68 schema.create_schema(self.conn) 82 schema.create_schema(self.conn)
69 self.conn.execute( 83 self.conn.execute(
@@ -80,6 +94,12 @@ class RunGraphTestBase(unittest.TestCase): @@ -80,6 +94,12 @@ class RunGraphTestBase(unittest.TestCase):
80 ) 94 )
81 self.conn.commit() 95 self.conn.commit()
82 96
  97 + def _restore_graphlemon_home(self):
  98 + if self.old_graphlemon_home is None:
  99 + os.environ.pop("GRAPHLEMON_HOME", None)
  100 + else:
  101 + os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home
  102 +
83 def tearDown(self): 103 def tearDown(self):
84 self.conn.close() 104 self.conn.close()
85 105
@@ -306,5 +326,83 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): @@ -306,5 +326,83 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase):
306 self.assertTrue(mock_run.call_args.kwargs["needs_worktree"]) 326 self.assertTrue(mock_run.call_args.kwargs["needs_worktree"])
307 327
308 328
  329 +class RunFnLogsExecutionEventsTest(RunGraphTestBase):
  330 + """DU-01 (bug A1/A2 root cause): `_make_run_fn()`'s `run_fn` closure must
  331 + actually call `execution_log.append_log()` around the real
  332 + `execute_work_graph()` call, so `execution_log_file`'s backing file on
  333 + disk genuinely has content -- not just a DB row tracking its lifecycle."""
  334 +
  335 + def _node_exec_id(self, node_id="brd"):
  336 + return self.conn.execute(
  337 + "SELECT node_exec_id FROM work_node_execution WHERE run_id = 'run-1' AND node_id = ?", (node_id,)
  338 + ).fetchone()[0]
  339 +
  340 + def _log_lines(self, node_exec_id):
  341 + content = execution_log.get_log(self.conn, node_exec_id)["content"]
  342 + return [json.loads(line) for line in content.splitlines() if line]
  343 +
  344 + def test_success_writes_node_start_then_node_completed(self):
  345 + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
  346 + with patch.object(work_graph_execute, "write_workflow_script"), \
  347 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "brd text"}):
  348 + wgo.run_graph(
  349 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  350 + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
  351 + )
  352 +
  353 + events = self._log_lines(self._node_exec_id())
  354 + self.assertEqual([e["event"] for e in events], ["node_start", "node_completed"])
  355 + self.assertEqual(events[1]["output"], {"content": "brd text"})
  356 +
  357 + # also exercise the real HTTP-facing read path end to end (not just
  358 + # the module function) -- an empty file was exactly bug A1.
  359 + row = self.conn.execute(
  360 + "SELECT size_bytes FROM execution_log_file WHERE node_exec_id = ?", (self._node_exec_id(),)
  361 + ).fetchone()
  362 + self.assertGreater(row[0], 0)
  363 +
  364 + def test_classifier_blocked_writes_node_failed_and_still_propagates(self):
  365 + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
  366 + with patch.object(work_graph_execute, "write_workflow_script"), \
  367 + patch.object(
  368 + work_graph_execute, "execute_work_graph",
  369 + side_effect=work_graph_execute.ClassifierBlockedError("blocked by classifier"),
  370 + ):
  371 + with self.assertRaises(work_graph_execute.ClassifierBlockedError):
  372 + wgo.run_graph(
  373 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  374 + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
  375 + )
  376 +
  377 + events = self._log_lines(self._node_exec_id())
  378 + self.assertEqual([e["event"] for e in events], ["node_start", "node_failed"])
  379 + self.assertIn("blocked by classifier", events[1]["error"])
  380 +
  381 + def test_usage_limit_writes_quota_limit_then_retries(self):
  382 + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
  383 + attempts = {"n": 0}
  384 +
  385 + def fake_execute(cwd, name, args, output_schema, timeout=600):
  386 + attempts["n"] += 1
  387 + if attempts["n"] == 1:
  388 + raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session")
  389 + return {"content": "recovered"}
  390 +
  391 + with patch.object(work_graph_execute, "write_workflow_script"), \
  392 + patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute):
  393 + outputs = wgo.run_graph(
  394 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  395 + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
  396 + sleep=lambda s: None, now=lambda: 0.0,
  397 + )
  398 + self.assertEqual(outputs["brd"], {"content": "recovered"})
  399 +
  400 + events = self._log_lines(self._node_exec_id())
  401 + self.assertEqual(
  402 + [e["event"] for e in events], ["node_start", "quota_limit", "node_start", "node_completed"]
  403 + )
  404 + self.assertEqual(events[1]["quota_limit_type"], "session")
  405 +
  406 +
309 if __name__ == "__main__": 407 if __name__ == "__main__":
310 unittest.main() 408 unittest.main()
src/work_graph_orchestrate.py
@@ -39,13 +39,42 @@ subgraph IS just itself), escalate to `work_graph_run.status='blocked'` + @@ -39,13 +39,42 @@ subgraph IS just itself), escalate to `work_graph_run.status='blocked'` +
39 raise if the regenerated content still fails. Not a general multi-node 39 raise if the regenerated content still fails. Not a general multi-node
40 re-planning mechanism -- that would be new, unscoped complexity 04-P itself 40 re-planning mechanism -- that would be new, unscoped complexity 04-P itself
41 doesn't describe either. 41 doesn't describe either.
  42 +
  43 +**DU-01 (2026-08-04, bug A1/A2 root-cause fix)**: `_make_run_fn()`'s `run_fn`
  44 +closure is the natural place to call `execution_log.append_log()` -- it's
  45 +the one spot that both has `node_exec_id` and wraps the actual blocking
  46 +`execute_work_graph()` call, and `work_node_run.run_work_node()` already
  47 +guarantees an `execution_log_file` row (status='active') exists before
  48 +`run_fn` is ever invoked. Before this, `append_log()` was fully implemented
  49 +(U-013/U-027) but never called by any production code path, so
  50 +`execution_log_file` rows existed with zero bytes of real content forever
  51 +-- the console's "暂无日志内容" was a correct rendering of genuinely empty
  52 +data, and downloading it crashed `backend_server._send_file_download()`
  53 +with an uncaught `FileNotFoundError` (also fixed here, see that function).
  54 +This is one blocking `agent()` call per node (03-C's own dynamic-workflow
  55 +design, see this module's own docstring above) with no mid-flight
  56 +progress to report, so logging is 4 discrete events around that one call,
  57 +not a real character-by-character stream: `node_start` (before the call),
  58 +`node_completed` (after a successful return), `node_failed` (any other
  59 +exception, re-raised unchanged after logging), `quota_limit` (the
  60 +`UsageLimitReachedError` case, logged before translating to
  61 +`work_node_run`'s own `_UsageLimitSignal`). A node retried by
  62 +`run_work_node`'s own retry loop calls `run_fn` again, appending another
  63 +`node_start`/... pair per attempt -- that's an accurate log of what
  64 +actually happened (N attempts), not a bug.
42 """ 65 """
43 66
44 import json 67 import json
45 import time 68 import time
46 import uuid 69 import uuid
47 70
48 -from src import paths, work_graph_execute, work_node_run 71 +from src import execution_log, paths, work_graph_execute, work_node_run
  72 +
  73 +
  74 +def _log_event(conn, node_exec_id, event_type, **payload):
  75 + execution_log.append_log(
  76 + conn, node_exec_id, json.dumps({"event": event_type, **payload}) + "\n"
  77 + )
49 78
50 79
51 class ConsistencyCheckFailedError(Exception): 80 class ConsistencyCheckFailedError(Exception):
@@ -138,19 +167,28 @@ def _render_node_script(script_name, org_node, prompt, output_schema, model): @@ -138,19 +167,28 @@ def _render_node_script(script_name, org_node, prompt, output_schema, model):
138 ) 167 )
139 168
140 169
141 -def _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): 170 +def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout):
142 script_name = "graphlemon-node-" + node_exec_id.replace("-", "") 171 script_name = "graphlemon-node-" + node_exec_id.replace("-", "")
143 172
144 def run_fn(cwd, model=None): 173 def run_fn(cwd, model=None):
  174 + _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model)
145 script_content = _render_node_script(script_name, org_node, prompt, output_schema, model) 175 script_content = _render_node_script(script_name, org_node, prompt, output_schema, model)
146 work_graph_execute.write_workflow_script(cwd, script_name, script_content) 176 work_graph_execute.write_workflow_script(cwd, script_name, script_content)
147 try: 177 try:
148 - return work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout) 178 + output = work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout)
149 except work_graph_execute.UsageLimitReachedError as e: 179 except work_graph_execute.UsageLimitReachedError as e:
  180 + _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type)
150 work_node_run.raise_usage_limit_signal(e.quota_limit_type) 181 work_node_run.raise_usage_limit_signal(e.quota_limit_type)
151 - # ClassifierBlockedError / generic WorkGraphExecuteError propagate  
152 - # as-is -- run_work_node's own `except Exception` clause marks the  
153 - # node 'blocked' and re-raises, no special handling needed here. 182 + except Exception as e:
  183 + # ClassifierBlockedError / generic WorkGraphExecuteError -- logged
  184 + # then propagated as-is; run_work_node's own `except Exception`
  185 + # clause marks the node 'blocked' and re-raises, no special
  186 + # handling needed here beyond recording what actually happened.
  187 + _log_event(conn, node_exec_id, "node_failed", error=str(e))
  188 + raise
  189 + else:
  190 + _log_event(conn, node_exec_id, "node_completed", output=output)
  191 + return output
154 192
155 return run_fn 193 return run_fn
156 194
@@ -215,7 +253,7 @@ def run_graph( @@ -215,7 +253,7 @@ def run_graph(
215 {"node_id": node_id}, 253 {"node_id": node_id},
216 ) 254 )
217 prompt = build_prompt(node, outputs) 255 prompt = build_prompt(node, outputs)
218 - run_fn = _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) 256 + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout)
219 output = work_node_run.run_work_node( 257 output = work_node_run.run_work_node(
220 conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, 258 conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn,
221 needs_worktree=effective_needs_worktree, repo_path=target_folder_path, 259 needs_worktree=effective_needs_worktree, repo_path=target_folder_path,
@@ -253,7 +291,9 @@ def _verify_and_maybe_regenerate( @@ -253,7 +291,9 @@ def _verify_and_maybe_regenerate(
253 prompt + "\n\nA consistency reviewer flagged issues with your previous draft: " 291 prompt + "\n\nA consistency reviewer flagged issues with your previous draft: "
254 + "; ".join(feedback or []) + "\nProduce a corrected version resolving these issues." 292 + "; ".join(feedback or []) + "\nProduce a corrected version resolving these issues."
255 ) 293 )
256 - reflect_run_fn = _make_run_fn(target_folder_path, reflect_node_exec_id, org_node, reflect_prompt, output_schema, timeout) 294 + reflect_run_fn = _make_run_fn(
  295 + conn, target_folder_path, reflect_node_exec_id, org_node, reflect_prompt, output_schema, timeout
  296 + )
257 regenerated = work_node_run.run_work_node( 297 regenerated = work_node_run.run_work_node(
258 conn, reflect_node_exec_id, {"node_id": node_id}, input_schema, output_schema, reflect_run_fn, 298 conn, reflect_node_exec_id, {"node_id": node_id}, input_schema, output_schema, reflect_run_fn,
259 needs_worktree=needs_worktree, repo_path=target_folder_path, 299 needs_worktree=needs_worktree, repo_path=target_folder_path,