Commit a991468d615866cbb4e38e39a13e6a4b3d6a8cc1

Authored by bboonnee
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>
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&#39;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&#39;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
... ...