Commit a991468d615866cbb4e38e39a13e6a4b3d6a8cc1
1 parent
a01d1c0e
Work Graph node execution deblackbox: drop native Dynamic Workflow for single-node calls
Implements docs/done/work-graph-node-execution-deblackbox-requirements.md end to end. The three documented call sites (e2e_pipeline.py:: _check_prd_consistency, work_graph_orchestrate.py::_llm_judgment_verify_fn, work_graph_orchestrate.py::_make_run_fn) all used to render a one-off Dynamic Workflow .js script and run it via `claude -p 'Run /<name>...'` purely to get "agent identity + schema-shaped output" -- a workaround for --json-schema silently conflicting with --agent (05 §5.4.1.1). That workaround was never actually necessary: work_graph_plan.py:: plan_work_graph() solved the identical problem from day one with a flat `claude --agent ... -p ...` call + ```json-fence extraction from free text, already validated in production. New org_node_trigger.trigger_org_node_structured() unifies all three call sites onto that same flat-call approach. extract_json_payload() generalizes work_graph_plan.py's fence-extraction algorithm; _extract_graph_manifest is now a thin wrapper over it. Two more call sites not in the original design doc (written before they existed) were found and migrated too: _run_reflection_perspective and _fix_skill_for_node (Phase 2a's Reflection-review/Fix-Skill). work_graph_fork.py needed no changes -- it only reaches _make_run_fn indirectly via run_graph(), so it benefits automatically. FR-4's real-agent verification (RUN_REAL_AGENT_E2E=1, mandatory, not optional -- src/test_org_node_trigger_e2e.py) confirmed persona loading, --model override, and JSON-fence extraction all work against a real model. write_workflow_script/execute_work_graph/execute_work_graph_with_usage_ limit_retry are confirmed to have no remaining production callers; docstrings mark them historical, the file is kept (not deleted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
14 changed files
with
677 additions
and
426 deletions
src/e2e_pipeline.py
| ... | ... | @@ -53,7 +53,7 @@ import uuid |
| 53 | 53 | |
| 54 | 54 | from src import ( |
| 55 | 55 | artifact_write, org_node_lifecycle, org_node_trigger, paths, task_create, |
| 56 | - work_graph_execute, work_graph_orchestrate, work_graph_plan, work_node_run, | |
| 56 | + work_graph_orchestrate, work_graph_plan, work_node_run, | |
| 57 | 57 | ) |
| 58 | 58 | |
| 59 | 59 | _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet." |
| ... | ... | @@ -378,16 +378,9 @@ def _check_prd_consistency(target_folder_path, prd_content, timeout=180): |
| 378 | 378 | worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None |
| 379 | 379 | cwd = worktree_path or target_folder_path |
| 380 | 380 | try: |
| 381 | - script_name = "graphlemon-prd-consistency-" + uuid.uuid4().hex | |
| 382 | 381 | prompt = _PRD_CONSISTENCY_PROMPT.format(prd_content=prd_content) |
| 383 | - script_content = ( | |
| 384 | - "export const meta = {{ name: {name}, description: 'graphlemon PRD consistency check' }}\n\n" | |
| 385 | - "const result = await agent({prompt}, {{ agentType: 'planner', schema: {schema} }})\n\n" | |
| 386 | - "return result\n" | |
| 387 | - ).format(name=json.dumps(script_name), prompt=json.dumps(prompt), schema=json.dumps(_PRD_CONSISTENCY_SCHEMA)) | |
| 388 | - work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 389 | - result = work_graph_execute.execute_work_graph( | |
| 390 | - cwd, script_name, {}, _PRD_CONSISTENCY_SCHEMA, timeout=timeout | |
| 382 | + result = org_node_trigger.trigger_org_node_structured( | |
| 383 | + "planner", prompt, cwd, _PRD_CONSISTENCY_SCHEMA, timeout=timeout | |
| 391 | 384 | ) |
| 392 | 385 | if worktree_path: |
| 393 | 386 | work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) | ... | ... |
src/org_node_trigger.py
| ... | ... | @@ -8,18 +8,155 @@ handling is U-010's job, not this one. |
| 8 | 8 | Identity comes entirely from `.claude/agents/<org_node>.md` in the target |
| 9 | 9 | project (see src/agent_profile.py) -- this module only assembles the prompt |
| 10 | 10 | and parses the response, no role-isolation logic of its own (03-C §3.3). |
| 11 | + | |
| 12 | +**`trigger_org_node_structured` (2026-08, work-graph-node-execution- | |
| 13 | +deblackbox-requirements.md)**: a sibling of `trigger_org_node` above, not a | |
| 14 | +replacement -- same flat `claude --agent` call, but also extracts a JSON | |
| 15 | +payload matching a caller-supplied output_schema from the free-text | |
| 16 | +response (05 §5.4.1.1's documented workaround: `--json-schema` silently | |
| 17 | +drops `structured_output` when combined with `--agent`, so ask for a single | |
| 18 | +```json fenced block in the prompt instead and parse it out, exactly the | |
| 19 | +convention `work_graph_plan.py::plan_work_graph()` already validated in | |
| 20 | +production). This is what closes the three remaining call sites that used | |
| 21 | +to render a one-off Dynamic Workflow `.js` script and run it via | |
| 22 | +`work_graph_execute.execute_work_graph()` purely to get "agent identity + | |
| 23 | +schema-shaped output" -- `plan_work_graph()` proves flat calls already do | |
| 24 | +both, so the Workflow indirection was never load-bearing for that need. | |
| 11 | 25 | """ |
| 12 | 26 | |
| 13 | 27 | import json |
| 14 | 28 | import subprocess |
| 15 | 29 | |
| 16 | 30 | from src.agent_profile import ORG_NODES |
| 31 | +from src.work_graph_plan import extract_json_payload | |
| 17 | 32 | |
| 18 | 33 | |
| 19 | 34 | class OrgNodeTriggerError(Exception): |
| 20 | 35 | """Raised when the claude subprocess exits non-zero or reports is_error.""" |
| 21 | 36 | |
| 22 | 37 | |
| 38 | +class StructuredOutputExtractionError(OrgNodeTriggerError): | |
| 39 | + """trigger_org_node_structured got a well-formed claude response, but | |
| 40 | + couldn't find any JSON payload in it matching output_schema's top-level | |
| 41 | + required keys (bare or ```json-fenced) -- see extract_json_payload.""" | |
| 42 | + | |
| 43 | + | |
| 44 | +# REQ-14's happy case cites "You've hit your session limit" as the real | |
| 45 | +# signal text (session/weekly/opus phrase matching, ported verbatim from | |
| 46 | +# work_graph_execute.py's own `_quota_limit_type` -- see this module's | |
| 47 | +# docstring for why trigger_org_node_structured needs THIS granular version | |
| 48 | +# (with a quota_limit_type of "session"/"weekly"/"opus") rather than | |
| 49 | +# org_node_lifecycle.py's simpler boolean `_looks_like_usage_limit`: | |
| 50 | +# work_node_run.raise_usage_limit_signal(quota_limit_type) and | |
| 51 | +# _make_run_fn's own quota_limit_type-keyed logging both need the specific | |
| 52 | +# type, not just a yes/no. org_node_lifecycle.py's own detection is a | |
| 53 | +# different call path (trigger_org_node_full, task-lifecycle transitions, | |
| 54 | +# untouched by this migration) and is deliberately left alone rather than | |
| 55 | +# unified here -- merging it would touch that unrelated flow's established, | |
| 56 | +# tested behavior for no benefit to this migration. work_graph_execute.py | |
| 57 | +# keeps its own copy too (frozen/historical per this migration's FR-6, not | |
| 58 | +# worth coupling a soon-to-be-unused module to this one) -- both copies | |
| 59 | +# implement the identical algorithm, so there is no *inconsistent* duplicate | |
| 60 | +# detection logic in play, only a frozen historical copy next to the live one. | |
| 61 | +_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit") | |
| 62 | + | |
| 63 | + | |
| 64 | +def _quota_limit_type(text): | |
| 65 | + lowered = text.lower() | |
| 66 | + if "weekly" in lowered and "hit your" in lowered: | |
| 67 | + return "weekly" | |
| 68 | + if "opus" in lowered and ("limit" in lowered or "quota" in lowered): | |
| 69 | + return "opus" | |
| 70 | + if any(phrase in lowered for phrase in _USAGE_LIMIT_PHRASES): | |
| 71 | + return "session" | |
| 72 | + return None | |
| 73 | + | |
| 74 | + | |
| 75 | +class UsageLimitReachedError(OrgNodeTriggerError): | |
| 76 | + """USAGE_LIMIT_REACHED -- 04-P p_usage_limit_wait. `quota_limit_type` is | |
| 77 | + "session"/"weekly"/"opus" (see _quota_limit_type above); callers that | |
| 78 | + already have a downgrade-retry mechanism (work_node_run.py's U-020 | |
| 79 | + model-downgrade retry, driven via work_node_run.raise_usage_limit_signal) | |
| 80 | + consume it directly.""" | |
| 81 | + | |
| 82 | + def __init__(self, message, quota_limit_type): | |
| 83 | + super().__init__(message) | |
| 84 | + self.quota_limit_type = quota_limit_type | |
| 85 | + | |
| 86 | + | |
| 87 | +_JSON_FENCE_INSTRUCTION = ( | |
| 88 | + "Your final answer MUST be a single JSON object wrapped in exactly one " | |
| 89 | + "```json fenced code block, matching this schema: {schema}. Do not " | |
| 90 | + "describe what you did or are about to do -- the fenced block's content " | |
| 91 | + "IS the answer, not a summary of it." | |
| 92 | +) | |
| 93 | + | |
| 94 | + | |
| 95 | +def trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300): | |
| 96 | + """Like trigger_org_node(), but also extracts a JSON payload matching | |
| 97 | + output_schema's top-level required keys from the free-text response -- | |
| 98 | + see this module's docstring. `_JSON_FENCE_INSTRUCTION` is appended to | |
| 99 | + `prompt` automatically, callers don't repeat this boilerplate themselves. | |
| 100 | + | |
| 101 | + `model`: real CLI `--model` override (FR-4) -- empirically verified for | |
| 102 | + real (RUN_REAL_AGENT_E2E=1, see this module's own test file) that | |
| 103 | + `claude --agent <name> -p ... --model <model>` (1) still loads the | |
| 104 | + target org_node's persona, (2) genuinely runs the requested model (the | |
| 105 | + JSON response's own `modelUsage` key names it), and (3) the ```json | |
| 106 | + fence-extraction convention above still works on that real output -- | |
| 107 | + 05 §5.4.1.5's "CLI top-level args and DSL-internal options are two | |
| 108 | + different paths, don't assume one from the other" lesson, checked here | |
| 109 | + rather than assumed. | |
| 110 | + """ | |
| 111 | + if org_node not in ORG_NODES: | |
| 112 | + raise ValueError(org_node) | |
| 113 | + | |
| 114 | + args = [ | |
| 115 | + "claude", "--agent", org_node, | |
| 116 | + "-p", prompt + "\n\n" + _JSON_FENCE_INSTRUCTION.format(schema=json.dumps(output_schema)), | |
| 117 | + ] | |
| 118 | + if model: | |
| 119 | + args += ["--model", model] | |
| 120 | + args += ["--output-format", "json", "--permission-mode", "auto"] | |
| 121 | + | |
| 122 | + result = subprocess.run( | |
| 123 | + args, cwd=target_folder_path, stdin=subprocess.DEVNULL, | |
| 124 | + capture_output=True, text=True, timeout=timeout, | |
| 125 | + ) | |
| 126 | + if result.returncode != 0: | |
| 127 | + quota_type = _quota_limit_type(result.stderr) or _quota_limit_type(result.stdout) | |
| 128 | + detail = "stderr: {}\nstdout (truncated): {}".format(result.stderr, result.stdout[:4000]) | |
| 129 | + if quota_type: | |
| 130 | + raise UsageLimitReachedError( | |
| 131 | + "trigger_org_node_structured for {} exited {}\n{}".format(org_node, result.returncode, detail), | |
| 132 | + quota_type, | |
| 133 | + ) | |
| 134 | + raise OrgNodeTriggerError( | |
| 135 | + "trigger_org_node_structured for {} exited {}\n{}".format(org_node, result.returncode, detail) | |
| 136 | + ) | |
| 137 | + | |
| 138 | + payload = json.loads(result.stdout) | |
| 139 | + if payload.get("is_error"): | |
| 140 | + quota_type = _quota_limit_type(str(payload.get("result", ""))) | |
| 141 | + if quota_type: | |
| 142 | + raise UsageLimitReachedError( | |
| 143 | + "trigger_org_node_structured for {} hit a usage limit: {}".format(org_node, payload), quota_type | |
| 144 | + ) | |
| 145 | + raise OrgNodeTriggerError( | |
| 146 | + "trigger_org_node_structured for {} reported is_error: {}".format(org_node, payload) | |
| 147 | + ) | |
| 148 | + | |
| 149 | + required_keys = list(output_schema.get("required", [])) | |
| 150 | + extracted = extract_json_payload(payload.get("result", ""), required_keys=required_keys) | |
| 151 | + if extracted is None: | |
| 152 | + raise StructuredOutputExtractionError( | |
| 153 | + "could not extract a payload matching required keys {} from org_node={}'s response: {!r}".format( | |
| 154 | + required_keys, org_node, payload.get("result", "")[:2000] | |
| 155 | + ) | |
| 156 | + ) | |
| 157 | + return extracted | |
| 158 | + | |
| 159 | + | |
| 23 | 160 | def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): |
| 24 | 161 | """Runs `claude --agent <org_node> -p <prompt>` with cwd=target_folder_path. |
| 25 | 162 | ... | ... |
src/test_e2e_pipeline.py
| ... | ... | @@ -8,16 +8,18 @@ src/test_e2e_pipeline_e2e.py (RUN_REAL_AGENT_E2E=1). |
| 8 | 8 | |
| 9 | 9 | **Rewired 2026-08-01 (U-031 regression audit)**: plan_documents/ |
| 10 | 10 | implement_code now go through work_graph_plan.plan_work_graph + |
| 11 | -work_graph_orchestrate.run_graph (which calls work_graph_execute. | |
| 12 | -execute_work_graph per node) instead of a flat org_node_trigger. | |
| 13 | -trigger_org_node call -- see e2e_pipeline.py's module docstring for why. | |
| 14 | -plan_work_graph is mocked at its own subprocess boundary (same pattern as | |
| 15 | -test_work_graph_plan.py) so its real graph_manifest-parsing logic and its | |
| 16 | -real work_graph_run INSERT both still run for real; execute_work_graph is | |
| 17 | -mocked directly (one level higher than its own subprocess boundary) since | |
| 18 | -this file's job is testing e2e_pipeline's wiring, not re-proving | |
| 19 | -execute_work_graph's own mechanics (covered in test_work_graph_execute.py) | |
| 20 | -or work_graph_orchestrate's own mechanics (test_work_graph_orchestrate.py). | |
| 11 | +work_graph_orchestrate.run_graph (which calls org_node_trigger. | |
| 12 | +trigger_org_node_structured per node, 2026-08 deblackbox migration -- | |
| 13 | +originally work_graph_execute.execute_work_graph, see that module's own | |
| 14 | +docstring) instead of a flat org_node_trigger.trigger_org_node call -- see | |
| 15 | +e2e_pipeline.py's module docstring for why. plan_work_graph is mocked at | |
| 16 | +its own subprocess boundary (same pattern as test_work_graph_plan.py) so | |
| 17 | +its real graph_manifest-parsing logic and its real work_graph_run INSERT | |
| 18 | +both still run for real; trigger_org_node_structured is mocked directly | |
| 19 | +(one level higher than its own subprocess boundary) since this file's job | |
| 20 | +is testing e2e_pipeline's wiring, not re-proving trigger_org_node_ | |
| 21 | +structured's own mechanics (covered in test_org_node_trigger.py) or | |
| 22 | +work_graph_orchestrate's own mechanics (test_work_graph_orchestrate.py). | |
| 21 | 23 | |
| 22 | 24 | Run: python3 -m unittest src.test_e2e_pipeline -v |
| 23 | 25 | """ |
| ... | ... | @@ -30,7 +32,7 @@ import tempfile |
| 30 | 32 | import unittest |
| 31 | 33 | from unittest.mock import patch |
| 32 | 34 | |
| 33 | -from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_execute, work_graph_orchestrate | |
| 35 | +from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_orchestrate | |
| 34 | 36 | |
| 35 | 37 | _FAKE_DOCS = { |
| 36 | 38 | "brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake", |
| ... | ... | @@ -303,9 +305,8 @@ class PlanDocumentsTest(PipelineTestBase): |
| 303 | 305 | self.conn.commit() |
| 304 | 306 | |
| 305 | 307 | with patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run), \ |
| 306 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 307 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 308 | - mock_execute.side_effect = [ | |
| 308 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 309 | + mock_trigger.side_effect = [ | |
| 309 | 310 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 310 | 311 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 311 | 312 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| ... | ... | @@ -374,9 +375,8 @@ class PlanDocumentsTest(PipelineTestBase): |
| 374 | 375 | return real_subprocess_run(*args, **kwargs) |
| 375 | 376 | |
| 376 | 377 | with patch("src.work_graph_plan.subprocess.run", side_effect=_discriminating_fake), \ |
| 377 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 378 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 379 | - mock_execute.side_effect = [ | |
| 378 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 379 | + mock_trigger.side_effect = [ | |
| 380 | 380 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 381 | 381 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 382 | 382 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| ... | ... | @@ -397,10 +397,9 @@ class RunEndToEndTest(PipelineTestBase): |
| 397 | 397 | def test_full_pipeline_wiring(self): |
| 398 | 398 | with patch("src.org_node_lifecycle.trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 399 | 399 | patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 400 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 401 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | |
| 400 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger, \ | |
| 402 | 401 | patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS\nAll scenarios pass."): |
| 403 | - mock_execute.side_effect = [ | |
| 402 | + mock_trigger.side_effect = [ | |
| 404 | 403 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 405 | 404 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 406 | 405 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| ... | ... | @@ -653,11 +652,11 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): |
| 653 | 652 | def test_non_prd_node_passes_through_without_a_call(self): |
| 654 | 653 | node_artifact_types = {"brd": "brd"} |
| 655 | 654 | verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 656 | - with patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 655 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 657 | 656 | ok, feedback = verify_fn({"node_id": "brd"}, {"content": "brd text"}) |
| 658 | 657 | self.assertTrue(ok) |
| 659 | 658 | self.assertIsNone(feedback) |
| 660 | - mock_execute.assert_not_called() | |
| 659 | + mock_trigger.assert_not_called() | |
| 661 | 660 | |
| 662 | 661 | def test_string_false_from_a_real_call_is_not_treated_as_truthy(self): |
| 663 | 662 | # regression: a real call (2026-08-02) returned "consistent": "false" |
| ... | ... | @@ -665,39 +664,37 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): |
| 665 | 664 | # Python, silently inverting the check until a real test caught it. |
| 666 | 665 | node_artifact_types = {"prd": "prd"} |
| 667 | 666 | verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 668 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 669 | - patch.object( | |
| 670 | - work_graph_execute, "execute_work_graph", | |
| 671 | - return_value={"consistent": "false", "issues": ["contradiction found"]}, | |
| 672 | - ): | |
| 667 | + with patch.object( | |
| 668 | + org_node_trigger, "trigger_org_node_structured", | |
| 669 | + return_value={"consistent": "false", "issues": ["contradiction found"]}, | |
| 670 | + ): | |
| 673 | 671 | ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"}) |
| 674 | 672 | self.assertFalse(ok) |
| 675 | 673 | |
| 676 | 674 | def test_string_true_from_a_real_call_is_treated_as_consistent(self): |
| 677 | 675 | node_artifact_types = {"prd": "prd"} |
| 678 | 676 | verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 679 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 680 | - patch.object( | |
| 681 | - work_graph_execute, "execute_work_graph", | |
| 682 | - return_value={"consistent": "true", "issues": []}, | |
| 683 | - ): | |
| 677 | + with patch.object( | |
| 678 | + org_node_trigger, "trigger_org_node_structured", | |
| 679 | + return_value={"consistent": "true", "issues": []}, | |
| 680 | + ): | |
| 684 | 681 | ok, feedback = verify_fn({"node_id": "prd"}, {"content": "consistent prd"}) |
| 685 | 682 | self.assertTrue(ok) |
| 686 | 683 | |
| 687 | 684 | def test_prd_node_triggers_a_real_check_call(self): |
| 688 | 685 | node_artifact_types = {"prd": "prd"} |
| 689 | 686 | verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 690 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 691 | - patch.object( | |
| 692 | - work_graph_execute, "execute_work_graph", | |
| 693 | - return_value={"consistent": False, "issues": ["contradiction found"]}, | |
| 694 | - ) as mock_execute: | |
| 687 | + with patch.object( | |
| 688 | + org_node_trigger, "trigger_org_node_structured", | |
| 689 | + return_value={"consistent": False, "issues": ["contradiction found"]}, | |
| 690 | + ) as mock_trigger: | |
| 695 | 691 | ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"}) |
| 696 | 692 | self.assertFalse(ok) |
| 697 | 693 | self.assertEqual(feedback, ["contradiction found"]) |
| 698 | - mock_execute.assert_called_once() | |
| 699 | - args = mock_execute.call_args[0] | |
| 700 | - self.assertEqual(args[0], "/tmp/proj-1") | |
| 694 | + mock_trigger.assert_called_once() | |
| 695 | + args = mock_trigger.call_args[0] | |
| 696 | + self.assertEqual(args[0], "planner") | |
| 697 | + self.assertEqual(args[2], "/tmp/proj-1") | |
| 701 | 698 | |
| 702 | 699 | |
| 703 | 700 | class CheckScriptCompilesTest(unittest.TestCase): |
| ... | ... | @@ -796,14 +793,13 @@ class CombinedPlannerVerifyFnTest(unittest.TestCase): |
| 796 | 793 | def test_prd_node_dispatches_to_prd_check(self): |
| 797 | 794 | node_artifact_types = {"n1": "prd"} |
| 798 | 795 | verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 799 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 800 | - patch.object( | |
| 801 | - work_graph_execute, "execute_work_graph", | |
| 802 | - return_value={"consistent": True, "issues": []}, | |
| 803 | - ) as mock_execute: | |
| 796 | + with patch.object( | |
| 797 | + org_node_trigger, "trigger_org_node_structured", | |
| 798 | + return_value={"consistent": True, "issues": []}, | |
| 799 | + ) as mock_trigger: | |
| 804 | 800 | ok, detail = verify_fn({"node_id": "n1"}, {"content": "a prd"}) |
| 805 | 801 | self.assertTrue(ok) |
| 806 | - mock_execute.assert_called_once() | |
| 802 | + mock_trigger.assert_called_once() | |
| 807 | 803 | |
| 808 | 804 | def test_test_script_node_dispatches_to_compile_check(self): |
| 809 | 805 | node_artifact_types = {"n1": "test_script"} |
| ... | ... | @@ -839,19 +835,18 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): |
| 839 | 835 | patch.object(e2e_pipeline.work_node_run, "ensure_worktree_committed") as mock_ensure, \ |
| 840 | 836 | patch.object(e2e_pipeline.work_node_run, "merge_worktree") as mock_merge, \ |
| 841 | 837 | patch.object(e2e_pipeline.work_node_run, "remove_worktree") as mock_remove, \ |
| 842 | - patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 843 | 838 | patch.object( |
| 844 | - work_graph_execute, "execute_work_graph", | |
| 839 | + org_node_trigger, "trigger_org_node_structured", | |
| 845 | 840 | return_value={"consistent": True, "issues": []}, |
| 846 | - ) as mock_execute: | |
| 841 | + ) as mock_trigger: | |
| 847 | 842 | ok, feedback = verify_fn({"node_id": "prd"}, {"content": "some prd"}) |
| 848 | 843 | |
| 849 | 844 | self.assertTrue(ok) |
| 850 | 845 | mock_create.assert_called_once_with("/repo", unittest.mock.ANY) |
| 851 | - # both the script write and the real execute call must use the | |
| 852 | - # WORKTREE path as cwd, never the main checkout, when self-targeting | |
| 853 | - self.assertEqual(mock_write.call_args[0][0], "/repo/.graphlemon-worktrees/x") | |
| 854 | - self.assertEqual(mock_execute.call_args[0][0], "/repo/.graphlemon-worktrees/x") | |
| 846 | + # the real trigger call must use the WORKTREE path as cwd (3rd | |
| 847 | + # positional arg: org_node, prompt, target_folder_path), never the | |
| 848 | + # main checkout, when self-targeting | |
| 849 | + self.assertEqual(mock_trigger.call_args[0][2], "/repo/.graphlemon-worktrees/x") | |
| 855 | 850 | # called twice: once for the worktree itself, once for the main |
| 856 | 851 | # checkout (worktrees share project identity, so a real memory |
| 857 | 852 | # write can land on the main checkout regardless of cwd -- see |
| ... | ... | @@ -868,16 +863,14 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): |
| 868 | 863 | verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") |
| 869 | 864 | with patch.object(e2e_pipeline.paths, "is_self_target", return_value=False), \ |
| 870 | 865 | patch.object(e2e_pipeline.work_node_run, "create_worktree") as mock_create, \ |
| 871 | - patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 872 | 866 | patch.object( |
| 873 | - work_graph_execute, "execute_work_graph", | |
| 867 | + org_node_trigger, "trigger_org_node_structured", | |
| 874 | 868 | return_value={"consistent": True, "issues": []}, |
| 875 | - ) as mock_execute: | |
| 869 | + ) as mock_trigger: | |
| 876 | 870 | verify_fn({"node_id": "prd"}, {"content": "some prd"}) |
| 877 | 871 | |
| 878 | 872 | mock_create.assert_not_called() |
| 879 | - self.assertEqual(mock_write.call_args[0][0], "/tmp/proj-1") | |
| 880 | - self.assertEqual(mock_execute.call_args[0][0], "/tmp/proj-1") | |
| 873 | + self.assertEqual(mock_trigger.call_args[0][2], "/tmp/proj-1") | |
| 881 | 874 | |
| 882 | 875 | |
| 883 | 876 | class ValidateImplementationTest(unittest.TestCase): |
| ... | ... | @@ -1104,9 +1097,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): |
| 1104 | 1097 | mock_responses.append({"content": script_content}) |
| 1105 | 1098 | mock_responses.append({"content": verdict_content}) |
| 1106 | 1099 | with patch("src.work_graph_plan.subprocess.run", side_effect=_fake_validator_plan_subprocess_run(manifest)), \ |
| 1107 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1108 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 1109 | - mock_execute.side_effect = mock_responses | |
| 1100 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 1101 | + mock_trigger.side_effect = mock_responses | |
| 1110 | 1102 | return e2e_pipeline.validate_implementation_with_own_graph( |
| 1111 | 1103 | self.conn, "task-1", "run-shared", "sdd content", "prd content", "goal text", self.tmp.name, |
| 1112 | 1104 | ) | ... | ... |
src/test_org_level_loop.py
| ... | ... | @@ -6,9 +6,10 @@ Testing constraint). Real coverage is in src/test_org_level_loop_e2e.py |
| 6 | 6 | **F (2026-08-06)**: root-cause judgment now runs through |
| 7 | 7 | root_cause_trace.judge_root_cause_via_work_graph (a real Work Graph fan-out, |
| 8 | 8 | see docs/done/verifier-fan-out-jury-design.md), so the 3 perspective calls |
| 9 | -are mocked at work_graph_execute.write_workflow_script/execute_work_graph | |
| 10 | -(same boundary as test_work_graph_orchestrate.py), not at | |
| 11 | -org_node_trigger.trigger_org_node -- that mock is now only used for the | |
| 9 | +are mocked at org_node_trigger.trigger_org_node_structured (2026-08 | |
| 10 | +deblackbox migration -- originally work_graph_execute.write_workflow_script/ | |
| 11 | +execute_work_graph, same boundary as test_work_graph_orchestrate.py), not | |
| 12 | +at org_node_trigger.trigger_org_node -- that mock is now only used for the | |
| 12 | 13 | fixer/verify calls, which are unchanged. |
| 13 | 14 | |
| 14 | 15 | Run: python3 -m unittest src.test_org_level_loop -v |
| ... | ... | @@ -17,16 +18,16 @@ Run: python3 -m unittest src.test_org_level_loop -v |
| 17 | 18 | import unittest |
| 18 | 19 | from unittest.mock import patch |
| 19 | 20 | |
| 20 | -from src import org_level_loop, root_cause_trace, schema, work_graph_execute | |
| 21 | +from src import org_level_loop, org_node_trigger, root_cause_trace, schema | |
| 21 | 22 | |
| 22 | 23 | |
| 23 | -def _make_perspective_execute(perspective_category): | |
| 24 | - """Fakes work_graph_execute.execute_work_graph for the 3 perspective | |
| 25 | - Work Nodes -- all agree on perspective_category, real schema-shaped | |
| 26 | - output (no more 'CATEGORY:'/'DETAIL:' text parsing).""" | |
| 27 | - def fake_execute(cwd, name, args, output_schema, timeout=300): | |
| 24 | +def _make_perspective_trigger(perspective_category): | |
| 25 | + """Fakes org_node_trigger.trigger_org_node_structured for the 3 | |
| 26 | + perspective Work Nodes -- all agree on perspective_category, real | |
| 27 | + schema-shaped output (no more 'CATEGORY:'/'DETAIL:' text parsing).""" | |
| 28 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 28 | 29 | return {"category": perspective_category, "detail": "because reasons"} |
| 29 | - return fake_execute | |
| 30 | + return fake_trigger | |
| 30 | 31 | |
| 31 | 32 | |
| 32 | 33 | def _make_trigger(fix_ok=True): |
| ... | ... | @@ -61,11 +62,10 @@ class RunFixLoopTestBase(unittest.TestCase): |
| 61 | 62 | self.conn.close() |
| 62 | 63 | |
| 63 | 64 | def _run_fix_loop(self, perspective_category, fix_ok=True): |
| 64 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 65 | - patch.object( | |
| 66 | - work_graph_execute, "execute_work_graph", | |
| 67 | - side_effect=_make_perspective_execute(perspective_category), | |
| 68 | - ), \ | |
| 65 | + with patch.object( | |
| 66 | + org_node_trigger, "trigger_org_node_structured", | |
| 67 | + side_effect=_make_perspective_trigger(perspective_category), | |
| 68 | + ), \ | |
| 69 | 69 | patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=fix_ok)): |
| 70 | 70 | return org_level_loop.run_fix_loop( |
| 71 | 71 | self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it" |
| ... | ... | @@ -118,11 +118,10 @@ class InconclusiveDisagreementTest(RunFixLoopTestBase): |
| 118 | 118 | def test_three_way_split_raises_and_creates_no_bug_record(self): |
| 119 | 119 | categories = iter(["planning", "coder", "skill_defect"]) |
| 120 | 120 | |
| 121 | - def fake_execute(cwd, name, args, output_schema, timeout=300): | |
| 121 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 122 | 122 | return {"category": next(categories), "detail": "d"} |
| 123 | 123 | |
| 124 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 125 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute), \ | |
| 124 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger), \ | |
| 126 | 125 | patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=True)): |
| 127 | 126 | with self.assertRaises(root_cause_trace.InconclusiveEscalateError): |
| 128 | 127 | org_level_loop.run_fix_loop( | ... | ... |
src/test_org_node_trigger.py
| ... | ... | @@ -62,5 +62,166 @@ class TriggerOrgNodeTest(unittest.TestCase): |
| 62 | 62 | org_node_trigger.trigger_org_node("validator", "hi", "/tmp/some-project") |
| 63 | 63 | |
| 64 | 64 | |
| 65 | +_SCHEMA = {"type": "object", "required": ["ok", "issues"], "properties": { | |
| 66 | + "ok": {"type": "boolean"}, "issues": {"type": "array"}, | |
| 67 | +}} | |
| 68 | + | |
| 69 | + | |
| 70 | +def _fenced(payload): | |
| 71 | + return "Here you go:\n\n```json\n" + json.dumps(payload) + "\n```\n\nDone." | |
| 72 | + | |
| 73 | + | |
| 74 | +class TriggerOrgNodeStructuredTest(unittest.TestCase): | |
| 75 | + def test_rejects_unknown_org_node_without_shelling_out(self): | |
| 76 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 77 | + with self.assertRaises(ValueError): | |
| 78 | + org_node_trigger.trigger_org_node_structured( | |
| 79 | + "not-a-real-node", "hi", "/tmp/some-project", _SCHEMA | |
| 80 | + ) | |
| 81 | + run.assert_not_called() | |
| 82 | + | |
| 83 | + def test_extracts_bare_json_result(self): | |
| 84 | + payload = {"ok": True, "issues": []} | |
| 85 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 86 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": json.dumps(payload)}) | |
| 87 | + extracted = org_node_trigger.trigger_org_node_structured( | |
| 88 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 89 | + ) | |
| 90 | + self.assertEqual(extracted, payload) | |
| 91 | + | |
| 92 | + def test_extracts_a_single_fenced_json_block_with_prose_around_it(self): | |
| 93 | + payload = {"ok": False, "issues": ["bug 1"]} | |
| 94 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 95 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": _fenced(payload)}) | |
| 96 | + extracted = org_node_trigger.trigger_org_node_structured( | |
| 97 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 98 | + ) | |
| 99 | + self.assertEqual(extracted, payload) | |
| 100 | + | |
| 101 | + def test_multiple_fenced_blocks_returns_first_matching_one(self): | |
| 102 | + unrelated = "```json\n" + json.dumps({"unrelated": True}) + "\n```\n\n" | |
| 103 | + payload = {"ok": True, "issues": []} | |
| 104 | + text = unrelated + _fenced(payload) | |
| 105 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 106 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": text}) | |
| 107 | + extracted = org_node_trigger.trigger_org_node_structured( | |
| 108 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 109 | + ) | |
| 110 | + self.assertEqual(extracted, payload) | |
| 111 | + | |
| 112 | + def test_no_matching_json_anywhere_raises_extraction_error(self): | |
| 113 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 114 | + run.return_value = _completed_process( | |
| 115 | + stdout_obj={"is_error": False, "result": "sorry, I can't produce that"} | |
| 116 | + ) | |
| 117 | + with self.assertRaises(org_node_trigger.StructuredOutputExtractionError): | |
| 118 | + org_node_trigger.trigger_org_node_structured( | |
| 119 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 120 | + ) | |
| 121 | + | |
| 122 | + def test_appends_json_fence_instruction_and_schema_to_prompt(self): | |
| 123 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 124 | + run.return_value = _completed_process( | |
| 125 | + stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})} | |
| 126 | + ) | |
| 127 | + org_node_trigger.trigger_org_node_structured("validator", "check this", "/tmp/some-project", _SCHEMA) | |
| 128 | + | |
| 129 | + argv = run.call_args[0][0] | |
| 130 | + prompt_arg = argv[argv.index("-p") + 1] | |
| 131 | + self.assertIn("check this", prompt_arg) | |
| 132 | + self.assertIn("```json fenced code block", prompt_arg) | |
| 133 | + self.assertIn(json.dumps(_SCHEMA), prompt_arg) | |
| 134 | + | |
| 135 | + def test_model_override_passed_through_as_cli_flag(self): | |
| 136 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 137 | + run.return_value = _completed_process( | |
| 138 | + stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})} | |
| 139 | + ) | |
| 140 | + org_node_trigger.trigger_org_node_structured( | |
| 141 | + "validator", "check this", "/tmp/some-project", _SCHEMA, model="claude-fable-5", | |
| 142 | + ) | |
| 143 | + | |
| 144 | + argv = run.call_args[0][0] | |
| 145 | + self.assertIn("--model", argv) | |
| 146 | + self.assertEqual(argv[argv.index("--model") + 1], "claude-fable-5") | |
| 147 | + | |
| 148 | + def test_no_model_means_no_model_flag(self): | |
| 149 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 150 | + run.return_value = _completed_process( | |
| 151 | + stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})} | |
| 152 | + ) | |
| 153 | + org_node_trigger.trigger_org_node_structured("validator", "check this", "/tmp/some-project", _SCHEMA) | |
| 154 | + | |
| 155 | + argv = run.call_args[0][0] | |
| 156 | + self.assertNotIn("--model", argv) | |
| 157 | + | |
| 158 | + def test_nonzero_exit_with_usage_limit_phrase_raises_usage_limit_error_with_type(self): | |
| 159 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 160 | + run.return_value = _completed_process( | |
| 161 | + returncode=1, stdout_obj=None, stderr="Claude AI usage limit reached, you've hit your session limit" | |
| 162 | + ) | |
| 163 | + with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx: | |
| 164 | + org_node_trigger.trigger_org_node_structured( | |
| 165 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 166 | + ) | |
| 167 | + self.assertEqual(ctx.exception.quota_limit_type, "session") | |
| 168 | + | |
| 169 | + def test_nonzero_exit_without_usage_limit_phrase_raises_generic_error(self): | |
| 170 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 171 | + run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom") | |
| 172 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | |
| 173 | + org_node_trigger.trigger_org_node_structured( | |
| 174 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 175 | + ) | |
| 176 | + | |
| 177 | + def test_is_error_with_weekly_limit_phrase_raises_usage_limit_error(self): | |
| 178 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 179 | + run.return_value = _completed_process( | |
| 180 | + stdout_obj={"is_error": True, "result": "You've hit your weekly limit"} | |
| 181 | + ) | |
| 182 | + with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx: | |
| 183 | + org_node_trigger.trigger_org_node_structured( | |
| 184 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 185 | + ) | |
| 186 | + self.assertEqual(ctx.exception.quota_limit_type, "weekly") | |
| 187 | + | |
| 188 | + def test_is_error_with_opus_quota_phrase_raises_usage_limit_error(self): | |
| 189 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 190 | + run.return_value = _completed_process( | |
| 191 | + stdout_obj={"is_error": True, "result": "You have reached your opus quota limit for this period"} | |
| 192 | + ) | |
| 193 | + with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx: | |
| 194 | + org_node_trigger.trigger_org_node_structured( | |
| 195 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 196 | + ) | |
| 197 | + self.assertEqual(ctx.exception.quota_limit_type, "opus") | |
| 198 | + | |
| 199 | + def test_is_error_without_usage_limit_phrase_raises_generic_error(self): | |
| 200 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 201 | + run.return_value = _completed_process(stdout_obj={"is_error": True, "result": "something else failed"}) | |
| 202 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | |
| 203 | + org_node_trigger.trigger_org_node_structured( | |
| 204 | + "validator", "check this", "/tmp/some-project", _SCHEMA | |
| 205 | + ) | |
| 206 | + | |
| 207 | + def test_invokes_claude_with_expected_cwd_and_stdin(self): | |
| 208 | + with patch("src.org_node_trigger.subprocess.run") as run: | |
| 209 | + run.return_value = _completed_process( | |
| 210 | + stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})} | |
| 211 | + ) | |
| 212 | + org_node_trigger.trigger_org_node_structured("coder", "do the thing", "/tmp/some-project", _SCHEMA) | |
| 213 | + | |
| 214 | + args, kwargs = run.call_args | |
| 215 | + argv = args[0] | |
| 216 | + self.assertEqual(argv[:3], ["claude", "--agent", "coder"]) | |
| 217 | + self.assertIn("--permission-mode", argv) | |
| 218 | + self.assertIn("auto", argv) | |
| 219 | + self.assertNotIn("--bare", argv) | |
| 220 | + self.assertNotIn("--dangerously-skip-permissions", argv) | |
| 221 | + self.assertNotIn("bypassPermissions", argv) | |
| 222 | + self.assertEqual(kwargs["cwd"], "/tmp/some-project") | |
| 223 | + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) | |
| 224 | + | |
| 225 | + | |
| 65 | 226 | if __name__ == "__main__": |
| 66 | 227 | unittest.main() | ... | ... |
src/test_root_cause_trace.py
| ... | ... | @@ -14,7 +14,7 @@ import time |
| 14 | 14 | import unittest |
| 15 | 15 | from unittest.mock import patch |
| 16 | 16 | |
| 17 | -from src import bug_record, root_cause_trace, schema, work_graph_execute | |
| 17 | +from src import bug_record, org_node_trigger, root_cause_trace, schema | |
| 18 | 18 | |
| 19 | 19 | |
| 20 | 20 | def _fixed(category, detail="detail"): |
| ... | ... | @@ -169,12 +169,11 @@ class JudgeRootCauseViaWorkGraphTestBase(unittest.TestCase): |
| 169 | 169 | def tearDown(self): |
| 170 | 170 | self.conn.close() |
| 171 | 171 | |
| 172 | - def _perspective_for_script(self, prompt_by_script, script_name): | |
| 173 | - content = prompt_by_script[script_name] | |
| 172 | + def _perspective_for_prompt(self, prompt): | |
| 174 | 173 | for name, framing in root_cause_trace._PERSPECTIVE_FRAMINGS.items(): |
| 175 | - if framing in content: | |
| 174 | + if framing in prompt: | |
| 176 | 175 | return name |
| 177 | - raise AssertionError("could not identify perspective for script " + script_name) | |
| 176 | + raise AssertionError("could not identify perspective for prompt " + prompt) | |
| 178 | 177 | |
| 179 | 178 | |
| 180 | 179 | class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase): |
| ... | ... | @@ -183,17 +182,11 @@ class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase): |
| 183 | 182 | calls inside one function.""" |
| 184 | 183 | |
| 185 | 184 | def test_three_perspectives_are_three_real_work_node_rows(self): |
| 186 | - prompt_by_script = {} | |
| 187 | - | |
| 188 | - def fake_write_script(cwd, script_name, script_content): | |
| 189 | - prompt_by_script[script_name] = script_content | |
| 190 | - | |
| 191 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 192 | - perspective = self._perspective_for_script(prompt_by_script, name) | |
| 185 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 186 | + perspective = self._perspective_for_prompt(prompt) | |
| 193 | 187 | return {"category": "coder", "detail": "agreed via " + perspective} |
| 194 | 188 | |
| 195 | - with patch.object(work_graph_execute, "write_workflow_script", side_effect=fake_write_script), \ | |
| 196 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 189 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 197 | 190 | result = root_cause_trace.judge_root_cause_via_work_graph( |
| 198 | 191 | self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", |
| 199 | 192 | ) |
| ... | ... | @@ -221,17 +214,11 @@ class JudgmentRuleEquivalenceTest(JudgeRootCauseViaWorkGraphTestBase): |
| 221 | 214 | same shared _tally_perspectives.""" |
| 222 | 215 | |
| 223 | 216 | def _run_with_categories(self, categories_by_perspective): |
| 224 | - prompt_by_script = {} | |
| 225 | - | |
| 226 | - def fake_write_script(cwd, script_name, script_content): | |
| 227 | - prompt_by_script[script_name] = script_content | |
| 228 | - | |
| 229 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 230 | - perspective = self._perspective_for_script(prompt_by_script, name) | |
| 217 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 218 | + perspective = self._perspective_for_prompt(prompt) | |
| 231 | 219 | return {"category": categories_by_perspective[perspective], "detail": "d-" + perspective} |
| 232 | 220 | |
| 233 | - with patch.object(work_graph_execute, "write_workflow_script", side_effect=fake_write_script), \ | |
| 234 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 221 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 235 | 222 | return root_cause_trace.judge_root_cause_via_work_graph( |
| 236 | 223 | self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", |
| 237 | 224 | ) |
| ... | ... | @@ -261,11 +248,10 @@ class IndependentCleanContextTest(JudgeRootCauseViaWorkGraphTestBase): |
| 261 | 248 | carries what it needs to judge -- not other perspectives' reasoning.""" |
| 262 | 249 | |
| 263 | 250 | def test_input_payload_has_no_cross_perspective_content(self): |
| 264 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 265 | - patch.object( | |
| 266 | - work_graph_execute, "execute_work_graph", | |
| 267 | - return_value={"category": "coder", "detail": "d"}, | |
| 268 | - ): | |
| 251 | + with patch.object( | |
| 252 | + org_node_trigger, "trigger_org_node_structured", | |
| 253 | + return_value={"category": "coder", "detail": "d"}, | |
| 254 | + ): | |
| 269 | 255 | root_cause_trace.judge_root_cause_via_work_graph( |
| 270 | 256 | self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", |
| 271 | 257 | ) |
| ... | ... | @@ -286,7 +272,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase): |
| 286 | 272 | events = [] |
| 287 | 273 | events_lock = threading.Lock() |
| 288 | 274 | |
| 289 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 275 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 290 | 276 | start = time.monotonic() |
| 291 | 277 | time.sleep(0.05) |
| 292 | 278 | end = time.monotonic() |
| ... | ... | @@ -294,8 +280,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase): |
| 294 | 280 | events.append((start, end)) |
| 295 | 281 | return {"category": "coder", "detail": "d"} |
| 296 | 282 | |
| 297 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 298 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 283 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 299 | 284 | root_cause_trace.judge_root_cause_via_work_graph( |
| 300 | 285 | self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", |
| 301 | 286 | ) | ... | ... |
src/test_scheduler.py
| ... | ... | @@ -19,8 +19,8 @@ import unittest |
| 19 | 19 | from unittest.mock import patch |
| 20 | 20 | |
| 21 | 21 | from src import ( |
| 22 | - bug_record, e2e_pipeline, org_level_loop, org_loop_route, paths, schema, scheduler, settings_config, | |
| 23 | - work_graph_execute, work_node_run, | |
| 22 | + bug_record, e2e_pipeline, org_level_loop, org_loop_route, org_node_trigger, paths, schema, scheduler, | |
| 23 | + settings_config, work_graph_execute, work_node_run, | |
| 24 | 24 | ) |
| 25 | 25 | |
| 26 | 26 | |
| ... | ... | @@ -417,9 +417,9 @@ def _fix_loop_trigger(org_node, prompt, target_folder_path, timeout=300): |
| 417 | 417 | raise AssertionError("unexpected call: " + org_node) |
| 418 | 418 | |
| 419 | 419 | |
| 420 | -def _fix_loop_perspective_execute(cwd, name, args, output_schema, timeout=300): | |
| 421 | - """work_graph_execute.execute_work_graph fake for run_fix_loop's 3 | |
| 422 | - root-cause perspective Work Nodes (F) -- all agree 'coder'.""" | |
| 420 | +def _fix_loop_perspective_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 421 | + """org_node_trigger.trigger_org_node_structured fake for run_fix_loop's | |
| 422 | + 3 root-cause perspective Work Nodes (F) -- all agree 'coder'.""" | |
| 423 | 423 | return {"category": "coder", "detail": "because reasons"} |
| 424 | 424 | |
| 425 | 425 | |
| ... | ... | @@ -466,8 +466,7 @@ class SchedulerContinuesPastBlockedTaskTest(unittest.TestCase): |
| 466 | 466 | with patch.object(scheduler.e2e_pipeline, "run_task_lifecycle", side_effect=fake_run_task_lifecycle), \ |
| 467 | 467 | patch.object(scheduler.e2e_pipeline, "_read_artifact_content", return_value="benchmark content"), \ |
| 468 | 468 | patch("src.org_node_trigger.trigger_org_node", side_effect=_fix_loop_trigger), \ |
| 469 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 470 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=_fix_loop_perspective_execute): | |
| 469 | + patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=_fix_loop_perspective_trigger): | |
| 471 | 470 | results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=3) |
| 472 | 471 | |
| 473 | 472 | by_goal = {r["goal_text"]: r for r in results} | ... | ... |
src/test_task_execution.py
| ... | ... | @@ -16,7 +16,7 @@ from unittest.mock import patch |
| 16 | 16 | |
| 17 | 17 | from src import ( |
| 18 | 18 | e2e_pipeline, org_node_lifecycle, org_node_trigger, project_scaffold, schema, scheduler, |
| 19 | - task_execution, work_graph_execute, work_graph_orchestrate, work_graph_plan, | |
| 19 | + task_execution, work_graph_orchestrate, work_graph_plan, | |
| 20 | 20 | ) |
| 21 | 21 | |
| 22 | 22 | |
| ... | ... | @@ -185,8 +185,7 @@ class ResumeFromCodingTest(TaskExecutionTestBase): |
| 185 | 185 | |
| 186 | 186 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 187 | 187 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 188 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 189 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 188 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute: | |
| 190 | 189 | # validator's self-planned final_verdict node (Phase 2c) |
| 191 | 190 | mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] |
| 192 | 191 | result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") |
| ... | ... | @@ -218,8 +217,7 @@ class ResumeFromPlanningTest(TaskExecutionTestBase): |
| 218 | 217 | |
| 219 | 218 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 220 | 219 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 221 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 222 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | |
| 220 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute, \ | |
| 223 | 221 | patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \ |
| 224 | 222 | patch.object(e2e_pipeline, "plan_documents") as mock_plan: |
| 225 | 223 | mock_execute.side_effect = [ |
| ... | ... | @@ -263,8 +261,7 @@ class ResumeFromReviewingTest(TaskExecutionTestBase): |
| 263 | 261 | |
| 264 | 262 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=tracking_ready_trigger), \ |
| 265 | 263 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 266 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 267 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | |
| 264 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute, \ | |
| 268 | 265 | patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \ |
| 269 | 266 | patch.object(e2e_pipeline, "plan_documents") as mock_plan: |
| 270 | 267 | mock_execute.side_effect = [ |
| ... | ... | @@ -313,8 +310,7 @@ class ResumeFromPlanningWithZeroArtifactsTest(TaskExecutionTestBase): |
| 313 | 310 | |
| 314 | 311 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 315 | 312 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 316 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 317 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | |
| 313 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute, \ | |
| 318 | 314 | patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"), \ |
| 319 | 315 | patch.object(e2e_pipeline, "plan_documents", side_effect=fake_plan_documents) as mock_plan: |
| 320 | 316 | mock_execute.side_effect = [ |
| ... | ... | @@ -349,8 +345,7 @@ class ResumeFromValidatingTest(TaskExecutionTestBase): |
| 349 | 345 | |
| 350 | 346 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger) as mock_ready, \ |
| 351 | 347 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 352 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 353 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 348 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute: | |
| 354 | 349 | mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node |
| 355 | 350 | result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") |
| 356 | 351 | |
| ... | ... | @@ -567,8 +562,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 567 | 562 | |
| 568 | 563 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 569 | 564 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 570 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 571 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 565 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute: | |
| 572 | 566 | mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node |
| 573 | 567 | thread = task_execution.start_task_execution_in_background( |
| 574 | 568 | self.db_path, "task-1", self.repo.name, "a goal", |
| ... | ... | @@ -592,8 +586,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 592 | 586 | # a second thread that would run the same task twice. |
| 593 | 587 | # |
| 594 | 588 | # Fully mocks the same real work the OTHER StartTaskExecutionIn |
| 595 | - # BackgroundTest tests mock (write_workflow_script/execute_work_graph/ | |
| 596 | - # org_node_trigger.trigger_org_node), not just the readiness trigger | |
| 589 | + # BackgroundTest tests mock (org_node_trigger.trigger_org_node_ | |
| 590 | + # structured/trigger_org_node), not just the readiness trigger | |
| 597 | 591 | # this test blocks on -- an earlier version of this test only |
| 598 | 592 | # patched the readiness trigger, so once unblocked the thread went on |
| 599 | 593 | # to attempt REAL subprocess/file work, outlived this test's own |
| ... | ... | @@ -618,8 +612,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 618 | 612 | |
| 619 | 613 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=blocking_trigger), \ |
| 620 | 614 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 621 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 622 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 615 | + patch.object(org_node_trigger, "trigger_org_node_structured") as mock_execute: | |
| 623 | 616 | mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node |
| 624 | 617 | thread = task_execution.start_task_execution_in_background( |
| 625 | 618 | self.db_path, "task-1", self.repo.name, "a goal", |
| ... | ... | @@ -644,8 +637,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 644 | 637 | |
| 645 | 638 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 646 | 639 | patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ |
| 647 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 648 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=RuntimeError("boom")): | |
| 640 | + patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=RuntimeError("boom")): | |
| 649 | 641 | # Phase 2c: Validator's own verdict is now a self-planned node, |
| 650 | 642 | # not a flat org_node_trigger.trigger_org_node call -- the |
| 651 | 643 | # failure this test cares about (a real exception surfacing as | ... | ... |
src/test_work_graph_fork.py
| ... | ... | @@ -12,7 +12,7 @@ import tempfile |
| 12 | 12 | import unittest |
| 13 | 13 | from unittest.mock import patch |
| 14 | 14 | |
| 15 | -from src import schema, work_graph_execute, work_graph_fork | |
| 15 | +from src import org_node_trigger, schema, work_graph_fork | |
| 16 | 16 | |
| 17 | 17 | |
| 18 | 18 | _BRD_PRD_SDD_BENCHMARK_MANIFEST = { |
| ... | ... | @@ -100,12 +100,11 @@ class ForkFromPrdAcceptanceCriterion1Test(ForkTestBase): |
| 100 | 100 | |
| 101 | 101 | execute_calls = [] |
| 102 | 102 | |
| 103 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 104 | - execute_calls.append(name) | |
| 105 | - return {"content": "regenerated for " + name} | |
| 103 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 104 | + execute_calls.append(prompt) | |
| 105 | + return {"content": "regenerated for " + prompt} | |
| 106 | 106 | |
| 107 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 108 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 107 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 109 | 108 | result = work_graph_fork.fork_work_graph_run( |
| 110 | 109 | self.conn, node_exec_ids["prd"], {"content": "a completely different prd"}, "/tmp/proj-1", |
| 111 | 110 | ) |
| ... | ... | @@ -165,12 +164,11 @@ class ForkFromMixedShapeGraphTest(ForkTestBase): |
| 165 | 164 | |
| 166 | 165 | execute_calls = [] |
| 167 | 166 | |
| 168 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 169 | - execute_calls.append(name) | |
| 170 | - return {"content": "regenerated for " + name} | |
| 167 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 168 | + execute_calls.append(prompt) | |
| 169 | + return {"content": "regenerated for " + prompt} | |
| 171 | 170 | |
| 172 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 173 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 171 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 174 | 172 | result = work_graph_fork.fork_work_graph_run( |
| 175 | 173 | self.conn, node_exec_ids["sdd"], {"content": "a completely different sdd"}, "/tmp/proj-1", |
| 176 | 174 | ) |
| ... | ... | @@ -199,8 +197,7 @@ class OriginalRunUnaffectedTest(ForkTestBase): |
| 199 | 197 | "SELECT * FROM work_node_execution WHERE run_id = 'run-orig' ORDER BY node_id" |
| 200 | 198 | ).fetchall() |
| 201 | 199 | |
| 202 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 203 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}): | |
| 200 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "x"}): | |
| 204 | 201 | work_graph_fork.fork_work_graph_run( |
| 205 | 202 | self.conn, node_exec_ids["prd"], {"content": "different"}, "/tmp/proj-1", |
| 206 | 203 | ) |
| ... | ... | @@ -226,13 +223,11 @@ class MultipleForksFromSameNodeTest(ForkTestBase): |
| 226 | 223 | "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents |
| 227 | 224 | ) |
| 228 | 225 | |
| 229 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 230 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "branch-a"}): | |
| 226 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "branch-a"}): | |
| 231 | 227 | result_a = work_graph_fork.fork_work_graph_run( |
| 232 | 228 | self.conn, node_exec_ids["prd"], {"content": "prd variant A"}, "/tmp/proj-1", |
| 233 | 229 | ) |
| 234 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 235 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "branch-b"}): | |
| 230 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "branch-b"}): | |
| 236 | 231 | result_b = work_graph_fork.fork_work_graph_run( |
| 237 | 232 | self.conn, node_exec_ids["prd"], {"content": "prd variant B"}, "/tmp/proj-1", |
| 238 | 233 | ) |
| ... | ... | @@ -345,8 +340,7 @@ class StartForkInBackgroundTest(unittest.TestCase): |
| 345 | 340 | os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home |
| 346 | 341 | |
| 347 | 342 | def test_returns_run_id_immediately_and_completes_in_background(self): |
| 348 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 349 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "regenerated"}): | |
| 343 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "regenerated"}): | |
| 350 | 344 | run_id, thread = work_graph_fork.start_fork_in_background( |
| 351 | 345 | self.db_path, "run-orig-prd", {"content": "new prd"}, "/tmp/proj-1", |
| 352 | 346 | ) |
| ... | ... | @@ -375,9 +369,8 @@ class ForkOfLeafNodeTest(ForkTestBase): |
| 375 | 369 | node_exec_ids = self._seed_completed_run( |
| 376 | 370 | "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents |
| 377 | 371 | ) |
| 378 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 379 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 380 | - mock_execute.side_effect = AssertionError("should not be called -- nothing downstream of benchmark") | |
| 372 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 373 | + mock_trigger.side_effect = AssertionError("should not be called -- nothing downstream of benchmark") | |
| 381 | 374 | result = work_graph_fork.fork_work_graph_run( |
| 382 | 375 | self.conn, node_exec_ids["benchmark"], {"content": "different benchmark"}, "/tmp/proj-1", |
| 383 | 376 | ) | ... | ... |
src/test_work_graph_orchestrate.py
| ... | ... | @@ -17,8 +17,8 @@ import unittest |
| 17 | 17 | from unittest.mock import patch |
| 18 | 18 | |
| 19 | 19 | from src import ( |
| 20 | - execution_log, paths, schema, skill_query, skill_update, | |
| 21 | - work_graph_execute, work_graph_orchestrate as wgo, work_node_run, | |
| 20 | + execution_log, org_node_trigger, paths, schema, skill_query, skill_update, | |
| 21 | + work_graph_orchestrate as wgo, work_node_run, | |
| 22 | 22 | ) |
| 23 | 23 | |
| 24 | 24 | |
| ... | ... | @@ -268,9 +268,8 @@ class RunGraphHappyPathTest(RunGraphTestBase): |
| 268 | 268 | calls.append((node["node_id"], dict(outputs))) |
| 269 | 269 | return "prompt for " + node["node_id"] |
| 270 | 270 | |
| 271 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 272 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 273 | - mock_execute.side_effect = [{"content": "brd text"}, {"content": "prd text"}] | |
| 271 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 272 | + mock_trigger.side_effect = [{"content": "brd text"}, {"content": "prd text"}] | |
| 274 | 273 | outputs = wgo.run_graph( |
| 275 | 274 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 276 | 275 | build_prompt, _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -282,8 +281,7 @@ class RunGraphHappyPathTest(RunGraphTestBase): |
| 282 | 281 | |
| 283 | 282 | def test_creates_work_node_execution_rows_and_placeholder_skill(self): |
| 284 | 283 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 285 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 286 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}): | |
| 284 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "x"}): | |
| 287 | 285 | wgo.run_graph( |
| 288 | 286 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 289 | 287 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -308,9 +306,10 @@ class RunGraphHappyPathTest(RunGraphTestBase): |
| 308 | 306 | def build_prompt(node, outputs): |
| 309 | 307 | return "p-" + node["node_id"] |
| 310 | 308 | |
| 311 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 312 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 313 | - mock_execute.side_effect = lambda cwd, name, args, schema, timeout=600: calls.append(name) or {"content": "c-" + name} | |
| 309 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 310 | + mock_trigger.side_effect = ( | |
| 311 | + lambda org_node, prompt, cwd, schema, model=None, timeout=300: calls.append(prompt) or {"content": "c-" + prompt} | |
| 312 | + ) | |
| 314 | 313 | wgo.run_graph( |
| 315 | 314 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 316 | 315 | build_prompt, _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -318,9 +317,8 @@ class RunGraphHappyPathTest(RunGraphTestBase): |
| 318 | 317 | self.assertEqual(len(calls), 2) |
| 319 | 318 | |
| 320 | 319 | # simulate a restart: run_graph called again for the same run_id |
| 321 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 322 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute2: | |
| 323 | - mock_execute2.side_effect = AssertionError("should not be called -- both nodes are already done") | |
| 320 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger2: | |
| 321 | + mock_trigger2.side_effect = AssertionError("should not be called -- both nodes are already done") | |
| 324 | 322 | outputs = wgo.run_graph( |
| 325 | 323 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 326 | 324 | build_prompt, _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -333,15 +331,14 @@ class RunGraphUsageLimitTest(RunGraphTestBase): |
| 333 | 331 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 334 | 332 | attempts = {"n": 0} |
| 335 | 333 | |
| 336 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 334 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 337 | 335 | attempts["n"] += 1 |
| 338 | 336 | if attempts["n"] == 1: |
| 339 | - raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session") | |
| 337 | + raise org_node_trigger.UsageLimitReachedError("hit your session limit", "session") | |
| 340 | 338 | return {"content": "recovered"} |
| 341 | 339 | |
| 342 | 340 | sleeps = [] |
| 343 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 344 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 341 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 345 | 342 | outputs = wgo.run_graph( |
| 346 | 343 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 347 | 344 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -353,13 +350,18 @@ class RunGraphUsageLimitTest(RunGraphTestBase): |
| 353 | 350 | |
| 354 | 351 | class RunGraphClassifierBlockedTest(RunGraphTestBase): |
| 355 | 352 | def test_classifier_blocked_marks_node_blocked_and_propagates(self): |
| 353 | + # trigger_org_node_structured doesn't replicate execute_work_graph's | |
| 354 | + # ClassifierBlockedError/permission_denials-threshold detection (no | |
| 355 | + # call site ever branched on that specific type -- _make_run_fn's | |
| 356 | + # own except-clause always treated it the same as any other generic | |
| 357 | + # failure) -- any non-usage-limit OrgNodeTriggerError exercises the | |
| 358 | + # exact same "mark node blocked and propagate" path. | |
| 356 | 359 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 357 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 358 | - patch.object( | |
| 359 | - work_graph_execute, "execute_work_graph", | |
| 360 | - side_effect=work_graph_execute.ClassifierBlockedError("blocked by classifier"), | |
| 361 | - ): | |
| 362 | - with self.assertRaises(work_graph_execute.ClassifierBlockedError): | |
| 360 | + with patch.object( | |
| 361 | + org_node_trigger, "trigger_org_node_structured", | |
| 362 | + side_effect=org_node_trigger.OrgNodeTriggerError("blocked by classifier"), | |
| 363 | + ): | |
| 364 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | |
| 363 | 365 | wgo.run_graph( |
| 364 | 366 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 365 | 367 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -389,8 +391,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase): |
| 389 | 391 | def bad_build_prompt(node, outputs): |
| 390 | 392 | raise KeyError("prd_content") |
| 391 | 393 | |
| 392 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 393 | - patch.object(work_graph_execute, "execute_work_graph"): | |
| 394 | + with patch.object(org_node_trigger, "trigger_org_node_structured"): | |
| 394 | 395 | with self.assertRaises(KeyError): |
| 395 | 396 | wgo.run_graph( |
| 396 | 397 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -410,8 +411,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase): |
| 410 | 411 | def bad_build_prompt(node, outputs): |
| 411 | 412 | raise RuntimeError("boom") |
| 412 | 413 | |
| 413 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 414 | - patch.object(work_graph_execute, "execute_work_graph"): | |
| 414 | + with patch.object(org_node_trigger, "trigger_org_node_structured"): | |
| 415 | 415 | with self.assertRaises(RuntimeError): |
| 416 | 416 | wgo.run_graph( |
| 417 | 417 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -427,15 +427,16 @@ class VerifyFnTest(RunGraphTestBase): |
| 427 | 427 | |
| 428 | 428 | def test_verify_fn_passing_immediately_does_not_regenerate(self): |
| 429 | 429 | manifest = {"nodes": [{"node_id": "prd", "depends_on": []}], "edges": []} |
| 430 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 431 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "consistent prd"}) as mock_execute: | |
| 430 | + with patch.object( | |
| 431 | + org_node_trigger, "trigger_org_node_structured", return_value={"content": "consistent prd"} | |
| 432 | + ) as mock_trigger: | |
| 432 | 433 | outputs = wgo.run_graph( |
| 433 | 434 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 434 | 435 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| 435 | 436 | verify_fn=lambda node, output: (True, None), |
| 436 | 437 | ) |
| 437 | 438 | self.assertEqual(outputs["prd"], {"content": "consistent prd"}) |
| 438 | - self.assertEqual(mock_execute.call_count, 1) | |
| 439 | + self.assertEqual(mock_trigger.call_count, 1) | |
| 439 | 440 | run_status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] |
| 440 | 441 | self.assertEqual(run_status, "running") # untouched -- verify_fn never failed |
| 441 | 442 | |
| ... | ... | @@ -448,8 +449,9 @@ class VerifyFnTest(RunGraphTestBase): |
| 448 | 449 | checked.append(output["content"]) |
| 449 | 450 | return output["content"] == "fixed prd", ["scenario A contradicts scenario B"] |
| 450 | 451 | |
| 451 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 452 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=lambda *a, **k: {"content": next(contents)}): | |
| 452 | + with patch.object( | |
| 453 | + org_node_trigger, "trigger_org_node_structured", side_effect=lambda *a, **k: {"content": next(contents)} | |
| 454 | + ): | |
| 453 | 455 | outputs = wgo.run_graph( |
| 454 | 456 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 455 | 457 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, verify_fn=verify_fn, |
| ... | ... | @@ -465,8 +467,9 @@ class VerifyFnTest(RunGraphTestBase): |
| 465 | 467 | |
| 466 | 468 | def test_verify_fn_failing_twice_raises_and_blocks_run(self): |
| 467 | 469 | manifest = {"nodes": [{"node_id": "prd", "depends_on": []}], "edges": []} |
| 468 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 469 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "still contradictory"}): | |
| 470 | + with patch.object( | |
| 471 | + org_node_trigger, "trigger_org_node_structured", return_value={"content": "still contradictory"} | |
| 472 | + ): | |
| 470 | 473 | with self.assertRaises(wgo.ConsistencyCheckFailedError): |
| 471 | 474 | wgo.run_graph( |
| 472 | 475 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -480,9 +483,9 @@ class VerifyFnTest(RunGraphTestBase): |
| 480 | 483 | class SelfTargetForcesWorktreeTest(RunGraphTestBase): |
| 481 | 484 | """U-035 (05 §5.8): self-target unconditionally forces needs_worktree=True, |
| 482 | 485 | regardless of what the caller passed. run_work_node itself is mocked here |
| 483 | - (not just execute_work_graph) so this only asserts what needs_worktree | |
| 484 | - value run_graph computes and passes down -- it doesn't exercise real git | |
| 485 | - worktree commands against this actual checkout.""" | |
| 486 | + (not just trigger_org_node_structured) so this only asserts what | |
| 487 | + needs_worktree value run_graph computes and passes down -- it doesn't | |
| 488 | + exercise real git worktree commands against this actual checkout.""" | |
| 486 | 489 | |
| 487 | 490 | def test_self_target_forces_worktree_even_when_caller_passed_false(self): |
| 488 | 491 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| ... | ... | @@ -490,8 +493,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): |
| 490 | 493 | "UPDATE project SET target_folder_path = ? WHERE project_id = 'proj-1'", (paths.repo_root(),) |
| 491 | 494 | ) |
| 492 | 495 | self.conn.commit() |
| 493 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 494 | - patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 496 | + with patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 495 | 497 | wgo.run_graph( |
| 496 | 498 | self.conn, "run-1", "planner", "planner-doc-writing", paths.repo_root(), manifest, |
| 497 | 499 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False, |
| ... | ... | @@ -500,8 +502,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): |
| 500 | 502 | |
| 501 | 503 | def test_non_self_target_respects_caller_supplied_false(self): |
| 502 | 504 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 503 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 504 | - patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 505 | + with patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 505 | 506 | wgo.run_graph( |
| 506 | 507 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 507 | 508 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False, |
| ... | ... | @@ -510,8 +511,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): |
| 510 | 511 | |
| 511 | 512 | def test_non_self_target_still_respects_caller_supplied_true(self): |
| 512 | 513 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 513 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 514 | - patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 514 | + with patch.object(work_node_run, "run_work_node", return_value={"content": "x"}) as mock_run: | |
| 515 | 515 | wgo.run_graph( |
| 516 | 516 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 517 | 517 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=True, |
| ... | ... | @@ -522,8 +522,9 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): |
| 522 | 522 | class RunFnLogsExecutionEventsTest(RunGraphTestBase): |
| 523 | 523 | """DU-01 (bug A1/A2 root cause): `_make_run_fn()`'s `run_fn` closure must |
| 524 | 524 | actually call `execution_log.append_log()` around the real |
| 525 | - `execute_work_graph()` call, so `execution_log_file`'s backing file on | |
| 526 | - disk genuinely has content -- not just a DB row tracking its lifecycle.""" | |
| 525 | + `trigger_org_node_structured()` call, so `execution_log_file`'s backing | |
| 526 | + file on disk genuinely has content -- not just a DB row tracking its | |
| 527 | + lifecycle.""" | |
| 527 | 528 | |
| 528 | 529 | def _node_exec_id(self, node_id="brd"): |
| 529 | 530 | return self.conn.execute( |
| ... | ... | @@ -536,8 +537,7 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): |
| 536 | 537 | |
| 537 | 538 | def test_success_writes_node_start_then_node_completed(self): |
| 538 | 539 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 539 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 540 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "brd text"}): | |
| 540 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "brd text"}): | |
| 541 | 541 | wgo.run_graph( |
| 542 | 542 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 543 | 543 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -556,12 +556,11 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): |
| 556 | 556 | |
| 557 | 557 | def test_classifier_blocked_writes_node_failed_and_still_propagates(self): |
| 558 | 558 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 559 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 560 | - patch.object( | |
| 561 | - work_graph_execute, "execute_work_graph", | |
| 562 | - side_effect=work_graph_execute.ClassifierBlockedError("blocked by classifier"), | |
| 563 | - ): | |
| 564 | - with self.assertRaises(work_graph_execute.ClassifierBlockedError): | |
| 559 | + with patch.object( | |
| 560 | + org_node_trigger, "trigger_org_node_structured", | |
| 561 | + side_effect=org_node_trigger.OrgNodeTriggerError("blocked by classifier"), | |
| 562 | + ): | |
| 563 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | |
| 565 | 564 | wgo.run_graph( |
| 566 | 565 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 567 | 566 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -575,14 +574,13 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): |
| 575 | 574 | manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} |
| 576 | 575 | attempts = {"n": 0} |
| 577 | 576 | |
| 578 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 577 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 579 | 578 | attempts["n"] += 1 |
| 580 | 579 | if attempts["n"] == 1: |
| 581 | - raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session") | |
| 580 | + raise org_node_trigger.UsageLimitReachedError("hit your session limit", "session") | |
| 582 | 581 | return {"content": "recovered"} |
| 583 | 582 | |
| 584 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 585 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 583 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 586 | 584 | outputs = wgo.run_graph( |
| 587 | 585 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 588 | 586 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -610,16 +608,15 @@ class RunGraphConcurrencyTest(RunGraphTestBase): |
| 610 | 608 | events = [] |
| 611 | 609 | events_lock = threading.Lock() |
| 612 | 610 | |
| 613 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 611 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 614 | 612 | start = time.monotonic() |
| 615 | 613 | time.sleep(0.05) |
| 616 | 614 | end = time.monotonic() |
| 617 | 615 | with events_lock: |
| 618 | 616 | events.append((start, end)) |
| 619 | - return {"content": name} | |
| 617 | + return {"content": prompt} | |
| 620 | 618 | |
| 621 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 622 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 619 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 623 | 620 | wgo.run_graph( |
| 624 | 621 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 625 | 622 | lambda node, outputs: "p-" + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -642,7 +639,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase): |
| 642 | 639 | events = [] |
| 643 | 640 | events_lock = threading.Lock() |
| 644 | 641 | |
| 645 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 642 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 646 | 643 | start = time.monotonic() |
| 647 | 644 | time.sleep(0.03) |
| 648 | 645 | end = time.monotonic() |
| ... | ... | @@ -650,8 +647,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase): |
| 650 | 647 | events.append((start, end)) |
| 651 | 648 | return {"content": "x"} |
| 652 | 649 | |
| 653 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 654 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 650 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 655 | 651 | wgo.run_graph( |
| 656 | 652 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 657 | 653 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -681,8 +677,7 @@ class RunGraphLayerErrorTest(RunGraphTestBase): |
| 681 | 677 | raise RuntimeError("boom on a") |
| 682 | 678 | return {"content": "b done"} |
| 683 | 679 | |
| 684 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 685 | - patch.object(work_node_run, "run_work_node", side_effect=fake_run_work_node): | |
| 680 | + with patch.object(work_node_run, "run_work_node", side_effect=fake_run_work_node): | |
| 686 | 681 | with self.assertRaises(wgo.WorkGraphLayerError) as ctx: |
| 687 | 682 | wgo.run_graph( |
| 688 | 683 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -707,21 +702,19 @@ class RunGraphConcurrentResumeTest(RunGraphTestBase): |
| 707 | 702 | } |
| 708 | 703 | calls = [] |
| 709 | 704 | |
| 710 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 711 | - calls.append(name) | |
| 712 | - return {"content": "c-" + name} | |
| 705 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 706 | + calls.append(prompt) | |
| 707 | + return {"content": "c-" + prompt} | |
| 713 | 708 | |
| 714 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 715 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 709 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 716 | 710 | wgo.run_graph( |
| 717 | 711 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 718 | 712 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| 719 | 713 | ) |
| 720 | 714 | self.assertEqual(len(calls), 2) |
| 721 | 715 | |
| 722 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 723 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute2: | |
| 724 | - mock_execute2.side_effect = AssertionError("should not be called -- both nodes already done") | |
| 716 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger2: | |
| 717 | + mock_trigger2.side_effect = AssertionError("should not be called -- both nodes already done") | |
| 725 | 718 | outputs = wgo.run_graph( |
| 726 | 719 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 727 | 720 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -740,17 +733,9 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase): |
| 740 | 733 | "nodes": [{"node_id": "a", "depends_on": []}, {"node_id": "b", "depends_on": []}], |
| 741 | 734 | "edges": [], |
| 742 | 735 | } |
| 743 | - prompt_by_script = {} | |
| 744 | - prompt_lock = threading.Lock() | |
| 745 | - | |
| 746 | - def fake_write_script(cwd, script_name, script_content): | |
| 747 | - with prompt_lock: | |
| 748 | - prompt_by_script[script_name] = script_content | |
| 749 | - | |
| 750 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 751 | - content = prompt_by_script[name] | |
| 752 | - if '"p-a"' in content: | |
| 753 | - raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session") | |
| 736 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 737 | + if prompt == "p-a": | |
| 738 | + raise org_node_trigger.UsageLimitReachedError("hit your session limit", "session") | |
| 754 | 739 | return {"content": "b done"} |
| 755 | 740 | |
| 756 | 741 | now_calls = {"n": 0} |
| ... | ... | @@ -764,8 +749,7 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase): |
| 764 | 749 | now_calls["n"] += 1 |
| 765 | 750 | return 0.0 if now_calls["n"] == 1 else 999999.0 |
| 766 | 751 | |
| 767 | - with patch.object(work_graph_execute, "write_workflow_script", side_effect=fake_write_script), \ | |
| 768 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 752 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 769 | 753 | with self.assertRaises(wgo.WorkGraphLayerError) as ctx: |
| 770 | 754 | wgo.run_graph( |
| 771 | 755 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -812,9 +796,9 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): |
| 812 | 796 | "edges": [], |
| 813 | 797 | } |
| 814 | 798 | |
| 815 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 799 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 816 | 800 | time.sleep(0.005) |
| 817 | - return {"content": name} | |
| 801 | + return {"content": prompt} | |
| 818 | 802 | |
| 819 | 803 | for round_n in range(5): |
| 820 | 804 | db_path = os.path.join(self.db_tmp.name, "test_{}.db".format(round_n)) |
| ... | ... | @@ -835,8 +819,7 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): |
| 835 | 819 | conn.commit() |
| 836 | 820 | |
| 837 | 821 | try: |
| 838 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 839 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 822 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 840 | 823 | outputs = wgo.run_graph( |
| 841 | 824 | conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 842 | 825 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -932,24 +915,22 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase): |
| 932 | 915 | judgment = {"judge_prompt": "Review: {content}"} |
| 933 | 916 | verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") |
| 934 | 917 | with patch.object(wgo.paths, "is_self_target", return_value=False), \ |
| 935 | - patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 936 | 918 | patch.object( |
| 937 | - work_graph_execute, "execute_work_graph", | |
| 919 | + org_node_trigger, "trigger_org_node_structured", | |
| 938 | 920 | return_value={"ok": True, "issues": []}, |
| 939 | - ) as mock_execute: | |
| 921 | + ) as mock_trigger: | |
| 940 | 922 | ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) |
| 941 | 923 | self.assertTrue(ok) |
| 942 | 924 | self.assertEqual(issues, []) |
| 943 | - self.assertEqual(mock_write.call_args[0][0], "/repo") | |
| 944 | - self.assertEqual(mock_execute.call_args[0][0], "/repo") | |
| 925 | + self.assertEqual(mock_trigger.call_args[0][0], "planner") | |
| 926 | + self.assertEqual(mock_trigger.call_args[0][2], "/repo") | |
| 945 | 927 | |
| 946 | 928 | def test_string_ok_is_coerced(self): |
| 947 | 929 | judgment = {"judge_prompt": "Review: {content}"} |
| 948 | 930 | verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") |
| 949 | 931 | with patch.object(wgo.paths, "is_self_target", return_value=False), \ |
| 950 | - patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 951 | 932 | patch.object( |
| 952 | - work_graph_execute, "execute_work_graph", | |
| 933 | + org_node_trigger, "trigger_org_node_structured", | |
| 953 | 934 | return_value={"ok": "false", "issues": ["bad"]}, |
| 954 | 935 | ): |
| 955 | 936 | ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) |
| ... | ... | @@ -965,16 +946,14 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase): |
| 965 | 946 | patch.object(wgo.work_node_run, "ensure_worktree_committed") as mock_ensure, \ |
| 966 | 947 | patch.object(wgo.work_node_run, "merge_worktree") as mock_merge, \ |
| 967 | 948 | patch.object(wgo.work_node_run, "remove_worktree") as mock_remove, \ |
| 968 | - patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 969 | 949 | patch.object( |
| 970 | - work_graph_execute, "execute_work_graph", | |
| 950 | + org_node_trigger, "trigger_org_node_structured", | |
| 971 | 951 | return_value={"ok": True, "issues": []}, |
| 972 | - ) as mock_execute: | |
| 952 | + ) as mock_trigger: | |
| 973 | 953 | ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) |
| 974 | 954 | self.assertTrue(ok) |
| 975 | 955 | mock_create.assert_called_once_with("/repo", unittest.mock.ANY) |
| 976 | - self.assertEqual(mock_write.call_args[0][0], "/repo/.graphlemon-worktrees/x") | |
| 977 | - self.assertEqual(mock_execute.call_args[0][0], "/repo/.graphlemon-worktrees/x") | |
| 956 | + self.assertEqual(mock_trigger.call_args[0][2], "/repo/.graphlemon-worktrees/x") | |
| 978 | 957 | self.assertEqual(mock_ensure.call_count, 2) |
| 979 | 958 | mock_merge.assert_called_once() |
| 980 | 959 | mock_remove.assert_called_once() |
| ... | ... | @@ -990,9 +969,8 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase): |
| 990 | 969 | "edges": [], |
| 991 | 970 | } |
| 992 | 971 | registry = {"compiles": lambda content: (content == "real code", None if content == "real code" else "not code")} |
| 993 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 994 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 995 | - mock_execute.side_effect = [{"content": "narrative text"}, {"content": "real code"}] | |
| 972 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 973 | + mock_trigger.side_effect = [{"content": "narrative text"}, {"content": "real code"}] | |
| 996 | 974 | outputs = wgo.run_graph( |
| 997 | 975 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 998 | 976 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -1005,15 +983,14 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase): |
| 1005 | 983 | "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}}], |
| 1006 | 984 | "edges": [], |
| 1007 | 985 | } |
| 1008 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1009 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 986 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 1010 | 987 | with self.assertRaises(ValueError): |
| 1011 | 988 | wgo.run_graph( |
| 1012 | 989 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1013 | 990 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| 1014 | 991 | mechanical_check_registry={"compiles": lambda c: (True, None)}, |
| 1015 | 992 | ) |
| 1016 | - mock_execute.assert_not_called() | |
| 993 | + mock_trigger.assert_not_called() | |
| 1017 | 994 | |
| 1018 | 995 | |
| 1019 | 996 | class RunGraphLoopBackTest(RunGraphTestBase): |
| ... | ... | @@ -1037,13 +1014,13 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1037 | 1014 | def build_prompt(node, outputs): |
| 1038 | 1015 | return "base prompt" |
| 1039 | 1016 | |
| 1040 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 1017 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 1018 | + prompts_seen.append(prompt) | |
| 1041 | 1019 | return {"content": contents.pop(0)} |
| 1042 | 1020 | |
| 1043 | 1021 | registry = {"compiles": lambda content: (content == "good", "not real code: " + content)} |
| 1044 | 1022 | |
| 1045 | - with patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 1046 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 1023 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 1047 | 1024 | outputs = wgo.run_graph( |
| 1048 | 1025 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1049 | 1026 | build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, |
| ... | ... | @@ -1067,18 +1044,13 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1067 | 1044 | self.assertEqual(payloads[1]["attempt_number"], 2) |
| 1068 | 1045 | |
| 1069 | 1046 | # the THIRD (successful) execution's prompt must carry both prior |
| 1070 | - # rejections' feedback, not just the most recent one. | |
| 1071 | - third_script_prompt = json.loads( | |
| 1072 | - self.conn.execute( | |
| 1073 | - "SELECT input_payload FROM work_node_execution WHERE node_id = 'benchmark-attempt3'" | |
| 1074 | - ).fetchone()[0] | |
| 1075 | - ) | |
| 1076 | - # the real prompt text lives in the rendered workflow script, not | |
| 1077 | - # input_payload (that's just {"node_id": ...}) -- inspect what got | |
| 1078 | - # passed to write_workflow_script for the third attempt instead. | |
| 1079 | - third_call_script = mock_write.call_args_list[2].args[2] | |
| 1080 | - self.assertIn("not real code: bad v1", third_call_script) | |
| 1081 | - self.assertIn("not real code: bad v2", third_call_script) | |
| 1047 | + # rejections' feedback, not just the most recent one -- inspect what | |
| 1048 | + # got passed as trigger_org_node_structured's own `prompt` argument | |
| 1049 | + # directly (input_payload is just {"node_id": ...}, the real prompt | |
| 1050 | + # text never round-trips through the DB). | |
| 1051 | + third_call_prompt = prompts_seen[2] | |
| 1052 | + self.assertIn("not real code: bad v1", third_call_prompt) | |
| 1053 | + self.assertIn("not real code: bad v2", third_call_prompt) | |
| 1082 | 1054 | |
| 1083 | 1055 | def test_loop_back_to_earlier_node_reruns_target_and_downstream(self): |
| 1084 | 1056 | # benchmark declares on_fail.action='loop_back' with an on_failure |
| ... | ... | @@ -1110,8 +1082,8 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1110 | 1082 | # sdd v1, bad benchmark, sdd v2 (re-run via loop_back), good benchmark. |
| 1111 | 1083 | call_order = [] |
| 1112 | 1084 | |
| 1113 | - def side_effect(cwd, name, args, output_schema, timeout=600): | |
| 1114 | - call_order.append(name) | |
| 1085 | + def side_effect(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 1086 | + call_order.append(prompt) | |
| 1115 | 1087 | call_index = len(call_order) |
| 1116 | 1088 | if call_index == 1: |
| 1117 | 1089 | return {"content": sdd_outputs[0]} |
| ... | ... | @@ -1123,8 +1095,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1123 | 1095 | |
| 1124 | 1096 | registry = {"compiles": lambda content: (content == "good benchmark", "rejected: " + content)} |
| 1125 | 1097 | |
| 1126 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1127 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect): | |
| 1098 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=side_effect): | |
| 1128 | 1099 | outputs = wgo.run_graph( |
| 1129 | 1100 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1130 | 1101 | build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, |
| ... | ... | @@ -1150,8 +1121,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1150 | 1121 | "edges": [], |
| 1151 | 1122 | } |
| 1152 | 1123 | registry = {"compiles": lambda content: (False, "always rejected")} |
| 1153 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1154 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "always bad"}): | |
| 1124 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "always bad"}): | |
| 1155 | 1125 | with self.assertRaises(wgo.ConsistencyCheckFailedError): |
| 1156 | 1126 | wgo.run_graph( |
| 1157 | 1127 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -1170,14 +1140,15 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1170 | 1140 | "edges": [], |
| 1171 | 1141 | } |
| 1172 | 1142 | registry = {"compiles": lambda content: (False, "bad")} |
| 1173 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1174 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}) as mock_execute: | |
| 1143 | + with patch.object( | |
| 1144 | + org_node_trigger, "trigger_org_node_structured", return_value={"content": "x"} | |
| 1145 | + ) as mock_trigger: | |
| 1175 | 1146 | with self.assertRaises(wgo.ConsistencyCheckFailedError): |
| 1176 | 1147 | wgo.run_graph( |
| 1177 | 1148 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1178 | 1149 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, |
| 1179 | 1150 | ) |
| 1180 | - self.assertEqual(mock_execute.call_count, 1) # no retry at all | |
| 1151 | + self.assertEqual(mock_trigger.call_count, 1) # no retry at all | |
| 1181 | 1152 | |
| 1182 | 1153 | def test_resume_after_restart_continues_from_the_real_attempt_count(self): |
| 1183 | 1154 | # Simulates a PRIOR process that ran attempt 1 (rejected), recorded |
| ... | ... | @@ -1221,8 +1192,9 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1221 | 1192 | # time succeeding -- must resume as attempt 2 (reading the true |
| 1222 | 1193 | # count from execution_event), not restart from attempt 1 or lose |
| 1223 | 1194 | # the earlier rejection's history. |
| 1224 | - with patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | |
| 1225 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "good"}): | |
| 1195 | + with patch.object( | |
| 1196 | + org_node_trigger, "trigger_org_node_structured", return_value={"content": "good"} | |
| 1197 | + ) as mock_trigger: | |
| 1226 | 1198 | outputs = wgo.run_graph( |
| 1227 | 1199 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1228 | 1200 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, |
| ... | ... | @@ -1232,7 +1204,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1232 | 1204 | "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id" |
| 1233 | 1205 | ).fetchall() |
| 1234 | 1206 | self.assertEqual([r[0] for r in rows], ["benchmark", "benchmark-attempt2"]) |
| 1235 | - self.assertIn("bad: bad v1", mock_write.call_args[0][2]) | |
| 1207 | + self.assertIn("bad: bad v1", mock_trigger.call_args[0][1]) | |
| 1236 | 1208 | |
| 1237 | 1209 | def test_org_node_type_scoped_config_limit_used_when_no_node_level_override(self): |
| 1238 | 1210 | from src import settings_config |
| ... | ... | @@ -1246,8 +1218,9 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1246 | 1218 | "edges": [], |
| 1247 | 1219 | } |
| 1248 | 1220 | registry = {"compiles": lambda content: (False, "bad")} |
| 1249 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1250 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}) as mock_execute: | |
| 1221 | + with patch.object( | |
| 1222 | + org_node_trigger, "trigger_org_node_structured", return_value={"content": "x"} | |
| 1223 | + ) as mock_trigger: | |
| 1251 | 1224 | with self.assertRaises(wgo.ConsistencyCheckFailedError): |
| 1252 | 1225 | wgo.run_graph( |
| 1253 | 1226 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| ... | ... | @@ -1258,7 +1231,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): |
| 1258 | 1231 | # attempt_number=1 on the first failure is not > 1, so one retry |
| 1259 | 1232 | # happens; attempt_number=2 on THAT failure is > 1 and exceeds. |
| 1260 | 1233 | # Two real executions total, not one. |
| 1261 | - self.assertEqual(mock_execute.call_count, 2) | |
| 1234 | + self.assertEqual(mock_trigger.call_count, 2) | |
| 1262 | 1235 | |
| 1263 | 1236 | |
| 1264 | 1237 | class NodeLevelSubagentSkillTest(RunGraphTestBase): |
| ... | ... | @@ -1274,8 +1247,7 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase): |
| 1274 | 1247 | ], |
| 1275 | 1248 | "edges": [], |
| 1276 | 1249 | } |
| 1277 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1278 | - patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}): | |
| 1250 | + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "x"}): | |
| 1279 | 1251 | wgo.run_graph( |
| 1280 | 1252 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1281 | 1253 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -1301,28 +1273,20 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase): |
| 1301 | 1273 | }], |
| 1302 | 1274 | "edges": [], |
| 1303 | 1275 | } |
| 1304 | - seen_agent_types = [] | |
| 1305 | - | |
| 1306 | - def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 1307 | - if "ok" in output_schema.get("required", []): | |
| 1308 | - return {"ok": True, "issues": []} | |
| 1309 | - return {"content": "x"} | |
| 1276 | + seen_org_nodes = [] | |
| 1310 | 1277 | |
| 1311 | - def fake_write(cwd, name, script_content): | |
| 1312 | - if "graphlemon Work Node" in script_content or "graphlemon node judgment" in script_content: | |
| 1313 | - seen_agent_types.append(script_content) | |
| 1278 | + def fake_trigger(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 1279 | + if "content" in output_schema.get("required", []): | |
| 1280 | + return {"content": "x"} | |
| 1281 | + seen_org_nodes.append(org_node) | |
| 1282 | + return {"ok": True, "issues": []} | |
| 1314 | 1283 | |
| 1315 | - with patch.object(work_graph_execute, "write_workflow_script", side_effect=fake_write), \ | |
| 1316 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 1284 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=fake_trigger): | |
| 1317 | 1285 | wgo.run_graph( |
| 1318 | 1286 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1319 | 1287 | lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, |
| 1320 | 1288 | ) |
| 1321 | - judgment_script = next(s for s in seen_agent_types if "graphlemon node judgment" in s) | |
| 1322 | - # _llm_judgment_verify_fn renders agentType as an unquoted JS | |
| 1323 | - # identifier key (agentType: "coder"), not a JSON key -- only the | |
| 1324 | - # VALUE is json.dumps'd. | |
| 1325 | - self.assertIn('agentType:"coder"', judgment_script.replace(" ", "")) | |
| 1289 | + self.assertEqual(seen_org_nodes, ["coder"]) | |
| 1326 | 1290 | |
| 1327 | 1291 | |
| 1328 | 1292 | class DynamicLoopBackContentErrorTest(RunGraphTestBase): |
| ... | ... | @@ -1347,10 +1311,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase): |
| 1347 | 1311 | node_call_count = [0] |
| 1348 | 1312 | lock = threading.Lock() |
| 1349 | 1313 | |
| 1350 | - def side_effect(cwd, name, args, output_schema, timeout=600): | |
| 1351 | - if name.startswith("graphlemon-reflection-"): | |
| 1314 | + def side_effect(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 1315 | + required = output_schema.get("required", []) | |
| 1316 | + if "target_node_id" in required: | |
| 1352 | 1317 | return {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "sdd is stale"} |
| 1353 | - if name.startswith("graphlemon-node-"): | |
| 1318 | + if "content" in required: | |
| 1354 | 1319 | with lock: |
| 1355 | 1320 | node_call_count[0] += 1 |
| 1356 | 1321 | idx = node_call_count[0] |
| ... | ... | @@ -1361,12 +1326,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase): |
| 1361 | 1326 | if idx == 3: |
| 1362 | 1327 | return {"content": sdd_contents[1]} |
| 1363 | 1328 | return {"content": "good final review"} |
| 1364 | - raise AssertionError("unexpected script name: " + name) | |
| 1329 | + raise AssertionError("unexpected output_schema: " + repr(output_schema)) | |
| 1365 | 1330 | |
| 1366 | 1331 | registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)} |
| 1367 | 1332 | |
| 1368 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1369 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect): | |
| 1333 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=side_effect): | |
| 1370 | 1334 | outputs = wgo.run_graph( |
| 1371 | 1335 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1372 | 1336 | lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, |
| ... | ... | @@ -1486,12 +1450,13 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase): |
| 1486 | 1450 | node_call_count = [0] |
| 1487 | 1451 | lock = threading.Lock() |
| 1488 | 1452 | |
| 1489 | - def side_effect(cwd, name, args, output_schema, timeout=600): | |
| 1490 | - if name.startswith("graphlemon-reflection-"): | |
| 1453 | + def side_effect(org_node, prompt, cwd, output_schema, model=None, timeout=300): | |
| 1454 | + required = output_schema.get("required", []) | |
| 1455 | + if "target_node_id" in required: | |
| 1491 | 1456 | return {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "systemic issue"} |
| 1492 | - if name.startswith("graphlemon-fixskill-"): | |
| 1457 | + if "new_content" in required: | |
| 1493 | 1458 | return {"new_content": "v2 content, fixed", "change_summary": "fixed the systemic issue"} |
| 1494 | - if name.startswith("graphlemon-node-"): | |
| 1459 | + if "content" in required: | |
| 1495 | 1460 | with lock: |
| 1496 | 1461 | node_call_count[0] += 1 |
| 1497 | 1462 | idx = node_call_count[0] |
| ... | ... | @@ -1500,12 +1465,11 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase): |
| 1500 | 1465 | if idx == 2: |
| 1501 | 1466 | return {"content": "bad final review"} |
| 1502 | 1467 | return {"content": "good final review"} |
| 1503 | - raise AssertionError("unexpected script name: " + name) | |
| 1468 | + raise AssertionError("unexpected output_schema: " + repr(output_schema)) | |
| 1504 | 1469 | |
| 1505 | 1470 | registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)} |
| 1506 | 1471 | |
| 1507 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 1508 | - patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect): | |
| 1472 | + with patch.object(org_node_trigger, "trigger_org_node_structured", side_effect=side_effect): | |
| 1509 | 1473 | outputs = wgo.run_graph( |
| 1510 | 1474 | self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, |
| 1511 | 1475 | lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, | ... | ... |
src/test_work_graph_plan.py
| ... | ... | @@ -12,7 +12,7 @@ import subprocess |
| 12 | 12 | import unittest |
| 13 | 13 | from unittest.mock import patch |
| 14 | 14 | |
| 15 | -from src import schema, work_graph_execute, work_graph_orchestrate, work_graph_plan | |
| 15 | +from src import org_node_trigger, schema, work_graph_orchestrate, work_graph_plan | |
| 16 | 16 | |
| 17 | 17 | _MANIFEST = { |
| 18 | 18 | "nodes": [ |
| ... | ... | @@ -571,9 +571,10 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase): |
| 571 | 571 | # plan_work_graph() already inserted the work_graph_run row |
| 572 | 572 | # (status='planning') -- run_graph() doesn't require any particular |
| 573 | 573 | # status on it, so nothing more to set up here. |
| 574 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 575 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 576 | - mock_execute.side_effect = lambda cwd, name, args, schema, timeout=600: {"content": "done: " + name} | |
| 574 | + with patch.object(org_node_trigger, "trigger_org_node_structured") as mock_trigger: | |
| 575 | + mock_trigger.side_effect = ( | |
| 576 | + lambda org_node, prompt, cwd, schema, model=None, timeout=300: {"content": "done: " + prompt} | |
| 577 | + ) | |
| 577 | 578 | outputs = work_graph_orchestrate.run_graph( |
| 578 | 579 | self.conn, plan_result["run_id"], "coder", "coder-implementation", "/tmp/proj-1", |
| 579 | 580 | plan_result["graph_manifest"], work_graph_plan.generic_node_prompt_builder, |
| ... | ... | @@ -581,7 +582,7 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase): |
| 581 | 582 | ) |
| 582 | 583 | |
| 583 | 584 | self.assertEqual(set(outputs.keys()), {"test-module-a", "test-module-b", "test-module-c"}) |
| 584 | - self.assertEqual(mock_execute.call_count, 3) | |
| 585 | + self.assertEqual(mock_trigger.call_count, 3) | |
| 585 | 586 | |
| 586 | 587 | |
| 587 | 588 | if __name__ == "__main__": | ... | ... |
src/work_graph_execute.py
| 1 | 1 | """c_work_graph_execute (03-C §3.5): dynamic-workflow invocation, full implementation. |
| 2 | 2 | |
| 3 | +**HISTORICAL, 2026-08 (work-graph-node-execution-deblackbox-requirements.md)**: | |
| 4 | +this module's `write_workflow_script`/`execute_work_graph`/`execute_work_ | |
| 5 | +graph_with_usage_limit_retry` are no longer called by any production code | |
| 6 | +path -- the three call sites that used to render a one-off Dynamic Workflow | |
| 7 | +`.js` script here purely to get "agent identity + schema-shaped output" now | |
| 8 | +use `org_node_trigger.trigger_org_node_structured()` (a flat `claude | |
| 9 | +--agent` call + ```json-fence extraction), the same mechanism `work_graph_ | |
| 10 | +plan.py::plan_work_graph()` had already been using in production the whole | |
| 11 | +time. Kept in this file (not deleted) as a historical record and because | |
| 12 | +`.claude/workflows/*.js` files already generated by real self-hosting runs | |
| 13 | +are real execution artifacts worth being able to re-read the generating | |
| 14 | +code for. Do not add new callers -- extend `org_node_trigger.py` instead. | |
| 15 | +See that module's docstring for the full rationale. | |
| 16 | + | |
| 3 | 17 | Writes an orchestration script into the target project's `.claude/workflows/ |
| 4 | 18 | <name>.js` and invokes it via `claude -p 'Run /<name> with args {...}'`, |
| 5 | 19 | reusing the exact code path the A-1 spike validated (spikes/a1-dynamic-workflows/, |
| ... | ... | @@ -68,7 +82,10 @@ def _quota_limit_type(text): |
| 68 | 82 | |
| 69 | 83 | def write_workflow_script(target_folder_path, name, script_content): |
| 70 | 84 | """Writes `<target>/.claude/workflows/<name>.js` -- versioned with the |
| 71 | - target project's own git history (03-C §3.5 step 1), not graphlemon's.""" | |
| 85 | + target project's own git history (03-C §3.5 step 1), not graphlemon's. | |
| 86 | + | |
| 87 | + Historical mechanism, no longer called by production code as of 2026-08 | |
| 88 | + (see this module's docstring) -- do not add new callers.""" | |
| 72 | 89 | workflows_dir = os.path.join(target_folder_path, ".claude", "workflows") |
| 73 | 90 | os.makedirs(workflows_dir, exist_ok=True) |
| 74 | 91 | path = os.path.join(workflows_dir, name + ".js") |
| ... | ... | @@ -87,6 +104,9 @@ def execute_work_graph(target_folder_path, name, args, output_schema, timeout=60 |
| 87 | 104 | `structured_output` dict. Raises ClassifierBlockedError or |
| 88 | 105 | UsageLimitReachedError instead of the generic WorkGraphExecuteError when |
| 89 | 106 | the response matches those specific signals. |
| 107 | + | |
| 108 | + Historical mechanism, no longer called by production code as of 2026-08 | |
| 109 | + (see this module's docstring) -- do not add new callers. | |
| 90 | 110 | """ |
| 91 | 111 | prompt = "Run /{} with args {}".format(name, json.dumps(args)) |
| 92 | 112 | result = subprocess.run( |
| ... | ... | @@ -157,6 +177,13 @@ def execute_work_graph_with_usage_limit_retry( |
| 157 | 177 | non-opus model" means here. |
| 158 | 178 | |
| 159 | 179 | `sleep`/`now` are injectable so tests don't block on real 5-30 minute waits. |
| 180 | + | |
| 181 | + Historical mechanism, no longer called by production code as of 2026-08 | |
| 182 | + (see this module's docstring) -- do not add new callers. FR-5's | |
| 183 | + equivalent retry-on-usage-limit behavior for the flat-call mechanism is | |
| 184 | + `work_node_run.py`'s own model-downgrade retry (U-020), driven via | |
| 185 | + `org_node_trigger.UsageLimitReachedError`/`work_node_run. | |
| 186 | + raise_usage_limit_signal` -- not a direct port of this function. | |
| 160 | 187 | """ |
| 161 | 188 | start = now() |
| 162 | 189 | backoff_index = 0 | ... | ... |
src/work_graph_orchestrate.py
| 1 | 1 | """Bridges `plan_work_graph()`'s `graph_manifest` to real per-node execution |
| 2 | -via `execute_work_graph()` + `run_work_node()` -- the missing integration | |
| 3 | -between three previously isolated, individually-tested modules (U-011/ | |
| 4 | -U-012/U-013), closed during U-031's regression audit (2026-08-01): each of | |
| 5 | -the three had its own unit tests (and even real-API validation) but none of | |
| 6 | -them had ever actually been called by production code, and the one | |
| 7 | -genuinely-validated closed loop (`e2e_pipeline.py`, U-014) used a much | |
| 8 | -simpler, separate mechanism (flat `org_node_trigger.trigger_org_node()` | |
| 9 | -calls) that bypassed all three. | |
| 10 | - | |
| 11 | -Design: each node's actual work happens via its OWN one-node dynamic | |
| 12 | -workflow script (`write_workflow_script` + `execute_work_graph`), not one | |
| 13 | -big multi-node script for the whole graph. This is a deliberate trade-off, | |
| 14 | -not the "amortize orchestration cost across many nodes in one `claude -p` | |
| 15 | -call" pattern 03-C §3.5 originally motivated `execute_work_graph` with -- | |
| 2 | +via `org_node_trigger.trigger_org_node_structured()` + `run_work_node()` -- | |
| 3 | +the missing integration between three previously isolated, individually- | |
| 4 | +tested modules (U-011/U-012/U-013), closed during U-031's regression audit | |
| 5 | +(2026-08-01): each of the three had its own unit tests (and even real-API | |
| 6 | +validation) but none of them had ever actually been called by production | |
| 7 | +code, and the one genuinely-validated closed loop (`e2e_pipeline.py`, | |
| 8 | +U-014) used a much simpler, separate mechanism (flat | |
| 9 | +`org_node_trigger.trigger_org_node()` calls) that bypassed all three. | |
| 10 | + | |
| 11 | +Design: each node's actual work happens via its OWN blocking | |
| 12 | +`trigger_org_node_structured()` call, one per node, not one call amortized | |
| 13 | +across the whole graph. This is a deliberate trade-off, not the "amortize | |
| 14 | +orchestration cost across many nodes in one `claude -p` call" pattern | |
| 15 | +03-C §3.5 originally motivated a whole-graph Dynamic Workflow script with -- | |
| 16 | 16 | REQ-06's breakpoint-recovery requirement (resume after a process restart |
| 17 | 17 | without re-running already-`done` nodes) is only achievable with per-node, |
| 18 | 18 | Python-level persisted state (`work_node_run.py`'s whole design), which is |
| 19 | 19 | fundamentally incompatible with wrapping the WHOLE graph in one opaque |
| 20 | 20 | `claude` subprocess call Python has no visibility into mid-flight. |
| 21 | 21 | |
| 22 | -Validated for real (2026-08-01): a one-node workflow script with a custom | |
| 23 | -`agentType` + `schema` genuinely loads the target org_node's migrated | |
| 24 | -persona (confirmed via a real call asking the agent to self-identify) and | |
| 25 | -returns real `structured_output` -- see 05 §5.4.1.5. | |
| 22 | +**2026-08 (work-graph-node-execution-deblackbox-requirements.md)**: this | |
| 23 | +module used to render each node's call as a one-off Dynamic Workflow `.js` | |
| 24 | +script (`work_graph_execute.write_workflow_script` + `.execute_work_graph`, | |
| 25 | +`claude -p 'Run /<name>...'`) purely to get "agent identity + schema-shaped | |
| 26 | +output" -- 05 §5.4.1.1's finding that `--json-schema` silently drops | |
| 27 | +`structured_output` when combined with `--agent` was, at the time (U-031, | |
| 28 | +2026-08-01), only known to be worked around inside a Workflow script's | |
| 29 | +`agent(prompt, {agentType, schema})` call. `work_graph_plan.py:: | |
| 30 | +plan_work_graph()` had already been doing the same "agent identity + schema | |
| 31 | +output" job with a flat `claude --agent` call + ```json-fence extraction the | |
| 32 | +whole time, in production, without ever needing Workflow -- so the | |
| 33 | +Workflow indirection was never actually load-bearing here either, just | |
| 34 | +unnoticed. `org_node_trigger.trigger_org_node_structured()` generalizes | |
| 35 | +that flat-call mechanism for any org_node/output_schema/model combination; | |
| 36 | +`work_graph_execute.py`'s `write_workflow_script`/`execute_work_graph` are | |
| 37 | +kept (not deleted) as a historical mechanism with no new callers -- see | |
| 38 | +that module's own docstring. | |
| 39 | + | |
| 40 | +Validated for real (2026-08-01, one-node Dynamic Workflow script; | |
| 41 | +re-validated 2026-08, flat `trigger_org_node_structured` call -- see | |
| 42 | +src/test_org_node_trigger_e2e.py): a custom `agentType`/`--agent` + | |
| 43 | +schema-extraction genuinely loads the target org_node's migrated persona | |
| 44 | +(confirmed via a real call asking the agent to self-identify) and returns | |
| 45 | +a real schema-shaped payload -- see 05 §5.4.1.5. | |
| 26 | 46 | |
| 27 | 47 | **`verify_fn` (2026-08-02, PH4-closure audit before starting PH5)**: closes |
| 28 | 48 | REQ-02's boundary case (PRD internal-contradiction check must intercept |
| ... | ... | @@ -43,17 +63,17 @@ doesn't describe either. |
| 43 | 63 | **DU-01 (2026-08-04, bug A1/A2 root-cause fix)**: `_make_run_fn()`'s `run_fn` |
| 44 | 64 | closure is the natural place to call `execution_log.append_log()` -- it's |
| 45 | 65 | 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 | |
| 66 | +`trigger_org_node_structured()` call, and `work_node_run.run_work_node()` | |
| 67 | +already guarantees an `execution_log_file` row (status='active') exists | |
| 68 | +before `run_fn` is ever invoked. Before this, `append_log()` was fully | |
| 69 | +implemented (U-013/U-027) but never called by any production code path, so | |
| 50 | 70 | `execution_log_file` rows existed with zero bytes of real content forever |
| 51 | 71 | -- the console's "暂无日志内容" was a correct rendering of genuinely empty |
| 52 | 72 | data, and downloading it crashed `backend_server._send_file_download()` |
| 53 | 73 | 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, | |
| 74 | +This is one blocking `agent()` call per node (see this module's own | |
| 75 | +docstring above) with no mid-flight progress to report, so logging is 4 | |
| 76 | +discrete events around that one call, | |
| 57 | 77 | not a real character-by-character stream: `node_start` (before the call), |
| 58 | 78 | `node_completed` (after a successful return), `node_failed` (any other |
| 59 | 79 | exception, re-raised unchanged after logging), `quota_limit` (the |
| ... | ... | @@ -72,7 +92,10 @@ import time |
| 72 | 92 | import uuid |
| 73 | 93 | from collections import Counter |
| 74 | 94 | |
| 75 | -from src import bug_record, execution_log, paths, settings_config, skill_query, skill_update, work_graph_execute, work_node_run | |
| 95 | +from src import ( | |
| 96 | + bug_record, execution_log, org_node_trigger, paths, settings_config, skill_query, skill_update, | |
| 97 | + work_node_run, | |
| 98 | +) | |
| 76 | 99 | |
| 77 | 100 | |
| 78 | 101 | def _log_event(conn, node_exec_id, event_type, **payload): |
| ... | ... | @@ -360,28 +383,14 @@ def _ensure_node_row(conn, run_id, node_id, subagent_type, skill_ref, input_sche |
| 360 | 383 | return node_exec_id |
| 361 | 384 | |
| 362 | 385 | |
| 363 | -def _render_node_script(script_name, org_node, prompt, output_schema, model): | |
| 364 | - model_opt = ", model: " + json.dumps(model) if model else "" | |
| 365 | - return ( | |
| 366 | - "export const meta = {{ name: {name}, description: 'graphlemon Work Node ({org_node})' }}\n\n" | |
| 367 | - "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema}{model_opt} }})\n\n" | |
| 368 | - "return result\n" | |
| 369 | - ).format( | |
| 370 | - name=json.dumps(script_name), org_node=org_node, org_node_json=json.dumps(org_node), | |
| 371 | - prompt=json.dumps(prompt), schema=json.dumps(output_schema), model_opt=model_opt, | |
| 372 | - ) | |
| 373 | - | |
| 374 | - | |
| 375 | 386 | def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): |
| 376 | - script_name = "graphlemon-node-" + node_exec_id.replace("-", "") | |
| 377 | - | |
| 378 | 387 | def run_fn(cwd, model=None): |
| 379 | 388 | _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model) |
| 380 | - script_content = _render_node_script(script_name, org_node, prompt, output_schema, model) | |
| 381 | - work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 382 | 389 | try: |
| 383 | - output = work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout) | |
| 384 | - except work_graph_execute.UsageLimitReachedError as e: | |
| 390 | + output = org_node_trigger.trigger_org_node_structured( | |
| 391 | + org_node, prompt, cwd, output_schema, model=model, timeout=timeout, | |
| 392 | + ) | |
| 393 | + except org_node_trigger.UsageLimitReachedError as e: | |
| 385 | 394 | _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type) |
| 386 | 395 | work_node_run.raise_usage_limit_signal(e.quota_limit_type) |
| 387 | 396 | except Exception as e: |
| ... | ... | @@ -445,17 +454,7 @@ def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180) |
| 445 | 454 | cwd = worktree_path or target_folder_path |
| 446 | 455 | try: |
| 447 | 456 | prompt = judgment["judge_prompt"].format(content=output["content"]) |
| 448 | - script_name = "graphlemon-judgment-" + uuid.uuid4().hex | |
| 449 | - script_content = ( | |
| 450 | - "export const meta = {{ name: {name}, description: 'graphlemon node judgment' }}\n\n" | |
| 451 | - "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema} }})\n\n" | |
| 452 | - "return result\n" | |
| 453 | - ).format( | |
| 454 | - name=json.dumps(script_name), prompt=json.dumps(prompt), | |
| 455 | - org_node_json=json.dumps(org_node), schema=json.dumps(_JUDGMENT_SCHEMA), | |
| 456 | - ) | |
| 457 | - work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 458 | - result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _JUDGMENT_SCHEMA, timeout=timeout) | |
| 457 | + result = org_node_trigger.trigger_org_node_structured(org_node, prompt, cwd, _JUDGMENT_SCHEMA, timeout=timeout) | |
| 459 | 458 | if worktree_path: |
| 460 | 459 | work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) |
| 461 | 460 | work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") |
| ... | ... | @@ -610,17 +609,9 @@ def _run_reflection_perspective(framing_name, prompt, target_folder_path, timeou |
| 610 | 609 | worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None |
| 611 | 610 | cwd = worktree_path or target_folder_path |
| 612 | 611 | try: |
| 613 | - script_name = "graphlemon-reflection-" + uuid.uuid4().hex | |
| 614 | - script_content = ( | |
| 615 | - "export const meta = {{ name: {name}, description: 'graphlemon Reflection-review ({framing})' }}\n\n" | |
| 616 | - "const result = await agent({prompt}, {{ agentType: 'validator', schema: {schema} }})\n\n" | |
| 617 | - "return result\n" | |
| 618 | - ).format( | |
| 619 | - name=json.dumps(script_name), framing=framing_name, prompt=json.dumps(prompt), | |
| 620 | - schema=json.dumps(_REFLECTION_OUTPUT_SCHEMA), | |
| 612 | + result = org_node_trigger.trigger_org_node_structured( | |
| 613 | + "validator", prompt, cwd, _REFLECTION_OUTPUT_SCHEMA, timeout=timeout | |
| 621 | 614 | ) |
| 622 | - work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 623 | - result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _REFLECTION_OUTPUT_SCHEMA, timeout=timeout) | |
| 624 | 615 | if worktree_path: |
| 625 | 616 | work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) |
| 626 | 617 | work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") |
| ... | ... | @@ -792,14 +783,9 @@ def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prom |
| 792 | 783 | worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None |
| 793 | 784 | cwd = worktree_path or target_folder_path |
| 794 | 785 | try: |
| 795 | - script_name = "graphlemon-fixskill-" + uuid.uuid4().hex | |
| 796 | - script_content = ( | |
| 797 | - "export const meta = {{ name: {name}, description: 'graphlemon Fix-Skill' }}\n\n" | |
| 798 | - "const result = await agent({prompt}, {{ agentType: 'validator', schema: {schema} }})\n\n" | |
| 799 | - "return result\n" | |
| 800 | - ).format(name=json.dumps(script_name), prompt=json.dumps(prompt), schema=json.dumps(_SKILL_REWRITE_SCHEMA)) | |
| 801 | - work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 802 | - rewrite_result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _SKILL_REWRITE_SCHEMA, timeout=timeout) | |
| 786 | + rewrite_result = org_node_trigger.trigger_org_node_structured( | |
| 787 | + "validator", prompt, cwd, _SKILL_REWRITE_SCHEMA, timeout=timeout | |
| 788 | + ) | |
| 803 | 789 | if worktree_path: |
| 804 | 790 | work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) |
| 805 | 791 | work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") |
| ... | ... | @@ -964,8 +950,8 @@ def run_graph( |
| 964 | 950 | mechanical_check_registry=None, |
| 965 | 951 | ): |
| 966 | 952 | """Runs every node in `graph_manifest` through `run_work_node()`, each |
| 967 | - backed by a real one-node dynamic-workflow `agent()` call using | |
| 968 | - `org_node`'s migrated persona. Nodes are grouped into topological | |
| 953 | + backed by a real `trigger_org_node_structured()` call using `org_node`'s | |
| 954 | + migrated persona. Nodes are grouped into topological | |
| 969 | 955 | layers (`topological_layers()`) and run layer-by-layer with a barrier |
| 970 | 956 | in between; within a layer -- nodes that are mutually independent by |
| 971 | 957 | construction -- a layer with 2+ nodes runs them concurrently via a | ... | ... |
src/work_graph_plan.py
| ... | ... | @@ -339,22 +339,44 @@ class PlanningFailedError(Exception): |
| 339 | 339 | """PLANNING_FAILED -- 03-C §3.4.""" |
| 340 | 340 | |
| 341 | 341 | |
| 342 | -def _extract_graph_manifest(text): | |
| 342 | +def extract_json_payload(text, required_keys=()): | |
| 343 | 343 | """--json-schema doesn't constrain output when --agent is set (see module |
| 344 | - docstring) -- pulls the graph_manifest out of free-form response text | |
| 344 | + docstring) -- pulls a JSON payload out of free-form response text | |
| 345 | 345 | instead: try the whole text as JSON, then each ```json fenced block; |
| 346 | - accept either {"nodes":...,"edges":...} directly or wrapped one level | |
| 347 | - deep under a "graph_manifest" key (models sometimes add that wrapper | |
| 348 | - despite being asked not to).""" | |
| 346 | + accept the first candidate that parses to a dict containing every key in | |
| 347 | + required_keys. Generalized (2026-08, work-graph-node-execution- | |
| 348 | + deblackbox) from what used to be `_extract_graph_manifest`'s own inline | |
| 349 | + algorithm, so `org_node_trigger.trigger_org_node_structured` can reuse it | |
| 350 | + for arbitrary output_schemas, not just graph_manifest's {nodes, edges} | |
| 351 | + shape. Wrapper-key fallbacks (like _extract_graph_manifest's own | |
| 352 | + "graph_manifest" key below) are caller-specific and deliberately left | |
| 353 | + out of this shared layer.""" | |
| 349 | 354 | candidates = [text] + _JSON_FENCE_RE.findall(text) |
| 350 | 355 | for candidate in candidates: |
| 351 | 356 | try: |
| 352 | 357 | parsed = json.loads(candidate) |
| 353 | 358 | except (json.JSONDecodeError, TypeError): |
| 354 | 359 | continue |
| 360 | + if isinstance(parsed, dict) and all(key in parsed for key in required_keys): | |
| 361 | + return parsed | |
| 362 | + return None | |
| 363 | + | |
| 364 | + | |
| 365 | +def _extract_graph_manifest(text): | |
| 366 | + """Thin wrapper around extract_json_payload: also accepts the payload | |
| 367 | + wrapped one level deep under a "graph_manifest" key (models sometimes | |
| 368 | + add that wrapper despite being asked not to) -- kept as its own function | |
| 369 | + rather than folding the wrapper-key check into extract_json_payload | |
| 370 | + itself, since no other caller needs this specific wrapper-key name.""" | |
| 371 | + result = extract_json_payload(text, required_keys=["nodes", "edges"]) | |
| 372 | + if result is not None: | |
| 373 | + return result | |
| 374 | + for candidate in [text] + _JSON_FENCE_RE.findall(text): | |
| 375 | + try: | |
| 376 | + parsed = json.loads(candidate) | |
| 377 | + except (json.JSONDecodeError, TypeError): | |
| 378 | + continue | |
| 355 | 379 | if isinstance(parsed, dict): |
| 356 | - if "nodes" in parsed and "edges" in parsed: | |
| 357 | - return parsed | |
| 358 | 380 | inner = parsed.get("graph_manifest") |
| 359 | 381 | if isinstance(inner, dict) and "nodes" in inner and "edges" in inner: |
| 360 | 382 | return inner | ... | ... |