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,7 +53,7 @@ import uuid
53 53
54 from src import ( 54 from src import (
55 artifact_write, org_node_lifecycle, org_node_trigger, paths, task_create, 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 _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet." 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,16 +378,9 @@ def _check_prd_consistency(target_folder_path, prd_content, timeout=180):
378 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None 378 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None
379 cwd = worktree_path or target_folder_path 379 cwd = worktree_path or target_folder_path
380 try: 380 try:
381 - script_name = "graphlemon-prd-consistency-" + uuid.uuid4().hex  
382 prompt = _PRD_CONSISTENCY_PROMPT.format(prd_content=prd_content) 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 if worktree_path: 385 if worktree_path:
393 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) 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,18 +8,155 @@ handling is U-010&#39;s job, not this one.
8 Identity comes entirely from `.claude/agents/<org_node>.md` in the target 8 Identity comes entirely from `.claude/agents/<org_node>.md` in the target
9 project (see src/agent_profile.py) -- this module only assembles the prompt 9 project (see src/agent_profile.py) -- this module only assembles the prompt
10 and parses the response, no role-isolation logic of its own (03-C §3.3). 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 import json 27 import json
14 import subprocess 28 import subprocess
15 29
16 from src.agent_profile import ORG_NODES 30 from src.agent_profile import ORG_NODES
  31 +from src.work_graph_plan import extract_json_payload
17 32
18 33
19 class OrgNodeTriggerError(Exception): 34 class OrgNodeTriggerError(Exception):
20 """Raised when the claude subprocess exits non-zero or reports is_error.""" 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 def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): 160 def trigger_org_node(org_node, prompt, target_folder_path, timeout=300):
24 """Runs `claude --agent <org_node> -p <prompt>` with cwd=target_folder_path. 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,16 +8,18 @@ src/test_e2e_pipeline_e2e.py (RUN_REAL_AGENT_E2E=1).
8 8
9 **Rewired 2026-08-01 (U-031 regression audit)**: plan_documents/ 9 **Rewired 2026-08-01 (U-031 regression audit)**: plan_documents/
10 implement_code now go through work_graph_plan.plan_work_graph + 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 Run: python3 -m unittest src.test_e2e_pipeline -v 24 Run: python3 -m unittest src.test_e2e_pipeline -v
23 """ 25 """
@@ -30,7 +32,7 @@ import tempfile @@ -30,7 +32,7 @@ import tempfile
30 import unittest 32 import unittest
31 from unittest.mock import patch 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 _FAKE_DOCS = { 37 _FAKE_DOCS = {
36 "brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake", 38 "brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake",
@@ -303,9 +305,8 @@ class PlanDocumentsTest(PipelineTestBase): @@ -303,9 +305,8 @@ class PlanDocumentsTest(PipelineTestBase):
303 self.conn.commit() 305 self.conn.commit()
304 306
305 with patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run), \ 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 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, 310 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
310 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) 311 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
311 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, 312 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
@@ -374,9 +375,8 @@ class PlanDocumentsTest(PipelineTestBase): @@ -374,9 +375,8 @@ class PlanDocumentsTest(PipelineTestBase):
374 return real_subprocess_run(*args, **kwargs) 375 return real_subprocess_run(*args, **kwargs)
375 376
376 with patch("src.work_graph_plan.subprocess.run", side_effect=_discriminating_fake), \ 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 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, 380 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
381 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) 381 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
382 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, 382 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
@@ -397,10 +397,9 @@ class RunEndToEndTest(PipelineTestBase): @@ -397,10 +397,9 @@ class RunEndToEndTest(PipelineTestBase):
397 def test_full_pipeline_wiring(self): 397 def test_full_pipeline_wiring(self):
398 with patch("src.org_node_lifecycle.trigger_org_node", side_effect=_fake_ready_trigger), \ 398 with patch("src.org_node_lifecycle.trigger_org_node", side_effect=_fake_ready_trigger), \
399 patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS\nAll scenarios pass."): 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 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, 403 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
405 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) 404 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
406 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, 405 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
@@ -653,11 +652,11 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): @@ -653,11 +652,11 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase):
653 def test_non_prd_node_passes_through_without_a_call(self): 652 def test_non_prd_node_passes_through_without_a_call(self):
654 node_artifact_types = {"brd": "brd"} 653 node_artifact_types = {"brd": "brd"}
655 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") 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 ok, feedback = verify_fn({"node_id": "brd"}, {"content": "brd text"}) 656 ok, feedback = verify_fn({"node_id": "brd"}, {"content": "brd text"})
658 self.assertTrue(ok) 657 self.assertTrue(ok)
659 self.assertIsNone(feedback) 658 self.assertIsNone(feedback)
660 - mock_execute.assert_not_called() 659 + mock_trigger.assert_not_called()
661 660
662 def test_string_false_from_a_real_call_is_not_treated_as_truthy(self): 661 def test_string_false_from_a_real_call_is_not_treated_as_truthy(self):
663 # regression: a real call (2026-08-02) returned "consistent": "false" 662 # regression: a real call (2026-08-02) returned "consistent": "false"
@@ -665,39 +664,37 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): @@ -665,39 +664,37 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase):
665 # Python, silently inverting the check until a real test caught it. 664 # Python, silently inverting the check until a real test caught it.
666 node_artifact_types = {"prd": "prd"} 665 node_artifact_types = {"prd": "prd"}
667 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") 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 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"}) 671 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"})
674 self.assertFalse(ok) 672 self.assertFalse(ok)
675 673
676 def test_string_true_from_a_real_call_is_treated_as_consistent(self): 674 def test_string_true_from_a_real_call_is_treated_as_consistent(self):
677 node_artifact_types = {"prd": "prd"} 675 node_artifact_types = {"prd": "prd"}
678 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") 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 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "consistent prd"}) 681 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "consistent prd"})
685 self.assertTrue(ok) 682 self.assertTrue(ok)
686 683
687 def test_prd_node_triggers_a_real_check_call(self): 684 def test_prd_node_triggers_a_real_check_call(self):
688 node_artifact_types = {"prd": "prd"} 685 node_artifact_types = {"prd": "prd"}
689 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") 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 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"}) 691 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "contradictory prd"})
696 self.assertFalse(ok) 692 self.assertFalse(ok)
697 self.assertEqual(feedback, ["contradiction found"]) 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 class CheckScriptCompilesTest(unittest.TestCase): 700 class CheckScriptCompilesTest(unittest.TestCase):
@@ -796,14 +793,13 @@ class CombinedPlannerVerifyFnTest(unittest.TestCase): @@ -796,14 +793,13 @@ class CombinedPlannerVerifyFnTest(unittest.TestCase):
796 def test_prd_node_dispatches_to_prd_check(self): 793 def test_prd_node_dispatches_to_prd_check(self):
797 node_artifact_types = {"n1": "prd"} 794 node_artifact_types = {"n1": "prd"}
798 verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1") 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 ok, detail = verify_fn({"node_id": "n1"}, {"content": "a prd"}) 800 ok, detail = verify_fn({"node_id": "n1"}, {"content": "a prd"})
805 self.assertTrue(ok) 801 self.assertTrue(ok)
806 - mock_execute.assert_called_once() 802 + mock_trigger.assert_called_once()
807 803
808 def test_test_script_node_dispatches_to_compile_check(self): 804 def test_test_script_node_dispatches_to_compile_check(self):
809 node_artifact_types = {"n1": "test_script"} 805 node_artifact_types = {"n1": "test_script"}
@@ -839,19 +835,18 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): @@ -839,19 +835,18 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase):
839 patch.object(e2e_pipeline.work_node_run, "ensure_worktree_committed") as mock_ensure, \ 835 patch.object(e2e_pipeline.work_node_run, "ensure_worktree_committed") as mock_ensure, \
840 patch.object(e2e_pipeline.work_node_run, "merge_worktree") as mock_merge, \ 836 patch.object(e2e_pipeline.work_node_run, "merge_worktree") as mock_merge, \
841 patch.object(e2e_pipeline.work_node_run, "remove_worktree") as mock_remove, \ 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 patch.object( 838 patch.object(
844 - work_graph_execute, "execute_work_graph", 839 + org_node_trigger, "trigger_org_node_structured",
845 return_value={"consistent": True, "issues": []}, 840 return_value={"consistent": True, "issues": []},
846 - ) as mock_execute: 841 + ) as mock_trigger:
847 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "some prd"}) 842 ok, feedback = verify_fn({"node_id": "prd"}, {"content": "some prd"})
848 843
849 self.assertTrue(ok) 844 self.assertTrue(ok)
850 mock_create.assert_called_once_with("/repo", unittest.mock.ANY) 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 # called twice: once for the worktree itself, once for the main 850 # called twice: once for the worktree itself, once for the main
856 # checkout (worktrees share project identity, so a real memory 851 # checkout (worktrees share project identity, so a real memory
857 # write can land on the main checkout regardless of cwd -- see 852 # write can land on the main checkout regardless of cwd -- see
@@ -868,16 +863,14 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): @@ -868,16 +863,14 @@ class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase):
868 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") 863 verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1")
869 with patch.object(e2e_pipeline.paths, "is_self_target", return_value=False), \ 864 with patch.object(e2e_pipeline.paths, "is_self_target", return_value=False), \
870 patch.object(e2e_pipeline.work_node_run, "create_worktree") as mock_create, \ 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 patch.object( 866 patch.object(
873 - work_graph_execute, "execute_work_graph", 867 + org_node_trigger, "trigger_org_node_structured",
874 return_value={"consistent": True, "issues": []}, 868 return_value={"consistent": True, "issues": []},
875 - ) as mock_execute: 869 + ) as mock_trigger:
876 verify_fn({"node_id": "prd"}, {"content": "some prd"}) 870 verify_fn({"node_id": "prd"}, {"content": "some prd"})
877 871
878 mock_create.assert_not_called() 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 class ValidateImplementationTest(unittest.TestCase): 876 class ValidateImplementationTest(unittest.TestCase):
@@ -1104,9 +1097,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): @@ -1104,9 +1097,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase):
1104 mock_responses.append({"content": script_content}) 1097 mock_responses.append({"content": script_content})
1105 mock_responses.append({"content": verdict_content}) 1098 mock_responses.append({"content": verdict_content})
1106 with patch("src.work_graph_plan.subprocess.run", side_effect=_fake_validator_plan_subprocess_run(manifest)), \ 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 return e2e_pipeline.validate_implementation_with_own_graph( 1102 return e2e_pipeline.validate_implementation_with_own_graph(
1111 self.conn, "task-1", "run-shared", "sdd content", "prd content", "goal text", self.tmp.name, 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,9 +6,10 @@ Testing constraint). Real coverage is in src/test_org_level_loop_e2e.py
6 **F (2026-08-06)**: root-cause judgment now runs through 6 **F (2026-08-06)**: root-cause judgment now runs through
7 root_cause_trace.judge_root_cause_via_work_graph (a real Work Graph fan-out, 7 root_cause_trace.judge_root_cause_via_work_graph (a real Work Graph fan-out,
8 see docs/done/verifier-fan-out-jury-design.md), so the 3 perspective calls 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 fixer/verify calls, which are unchanged. 13 fixer/verify calls, which are unchanged.
13 14
14 Run: python3 -m unittest src.test_org_level_loop -v 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,16 +18,16 @@ Run: python3 -m unittest src.test_org_level_loop -v
17 import unittest 18 import unittest
18 from unittest.mock import patch 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 return {"category": perspective_category, "detail": "because reasons"} 29 return {"category": perspective_category, "detail": "because reasons"}
29 - return fake_execute 30 + return fake_trigger
30 31
31 32
32 def _make_trigger(fix_ok=True): 33 def _make_trigger(fix_ok=True):
@@ -61,11 +62,10 @@ class RunFixLoopTestBase(unittest.TestCase): @@ -61,11 +62,10 @@ class RunFixLoopTestBase(unittest.TestCase):
61 self.conn.close() 62 self.conn.close()
62 63
63 def _run_fix_loop(self, perspective_category, fix_ok=True): 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 patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=fix_ok)): 69 patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=fix_ok)):
70 return org_level_loop.run_fix_loop( 70 return org_level_loop.run_fix_loop(
71 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it" 71 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it"
@@ -118,11 +118,10 @@ class InconclusiveDisagreementTest(RunFixLoopTestBase): @@ -118,11 +118,10 @@ class InconclusiveDisagreementTest(RunFixLoopTestBase):
118 def test_three_way_split_raises_and_creates_no_bug_record(self): 118 def test_three_way_split_raises_and_creates_no_bug_record(self):
119 categories = iter(["planning", "coder", "skill_defect"]) 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 return {"category": next(categories), "detail": "d"} 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 patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=True)): 125 patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger(fix_ok=True)):
127 with self.assertRaises(root_cause_trace.InconclusiveEscalateError): 126 with self.assertRaises(root_cause_trace.InconclusiveEscalateError):
128 org_level_loop.run_fix_loop( 127 org_level_loop.run_fix_loop(
src/test_org_node_trigger.py
@@ -62,5 +62,166 @@ class TriggerOrgNodeTest(unittest.TestCase): @@ -62,5 +62,166 @@ class TriggerOrgNodeTest(unittest.TestCase):
62 org_node_trigger.trigger_org_node("validator", "hi", "/tmp/some-project") 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 if __name__ == "__main__": 226 if __name__ == "__main__":
66 unittest.main() 227 unittest.main()
src/test_root_cause_trace.py
@@ -14,7 +14,7 @@ import time @@ -14,7 +14,7 @@ import time
14 import unittest 14 import unittest
15 from unittest.mock import patch 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 def _fixed(category, detail="detail"): 20 def _fixed(category, detail="detail"):
@@ -169,12 +169,11 @@ class JudgeRootCauseViaWorkGraphTestBase(unittest.TestCase): @@ -169,12 +169,11 @@ class JudgeRootCauseViaWorkGraphTestBase(unittest.TestCase):
169 def tearDown(self): 169 def tearDown(self):
170 self.conn.close() 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 for name, framing in root_cause_trace._PERSPECTIVE_FRAMINGS.items(): 173 for name, framing in root_cause_trace._PERSPECTIVE_FRAMINGS.items():
175 - if framing in content: 174 + if framing in prompt:
176 return name 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 class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase): 179 class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase):
@@ -183,17 +182,11 @@ class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase): @@ -183,17 +182,11 @@ class RealWorkNodesPerPerspectiveTest(JudgeRootCauseViaWorkGraphTestBase):
183 calls inside one function.""" 182 calls inside one function."""
184 183
185 def test_three_perspectives_are_three_real_work_node_rows(self): 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 return {"category": "coder", "detail": "agreed via " + perspective} 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 result = root_cause_trace.judge_root_cause_via_work_graph( 190 result = root_cause_trace.judge_root_cause_via_work_graph(
198 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", 191 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context",
199 ) 192 )
@@ -221,17 +214,11 @@ class JudgmentRuleEquivalenceTest(JudgeRootCauseViaWorkGraphTestBase): @@ -221,17 +214,11 @@ class JudgmentRuleEquivalenceTest(JudgeRootCauseViaWorkGraphTestBase):
221 same shared _tally_perspectives.""" 214 same shared _tally_perspectives."""
222 215
223 def _run_with_categories(self, categories_by_perspective): 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 return {"category": categories_by_perspective[perspective], "detail": "d-" + perspective} 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 return root_cause_trace.judge_root_cause_via_work_graph( 222 return root_cause_trace.judge_root_cause_via_work_graph(
236 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", 223 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context",
237 ) 224 )
@@ -261,11 +248,10 @@ class IndependentCleanContextTest(JudgeRootCauseViaWorkGraphTestBase): @@ -261,11 +248,10 @@ class IndependentCleanContextTest(JudgeRootCauseViaWorkGraphTestBase):
261 carries what it needs to judge -- not other perspectives' reasoning.""" 248 carries what it needs to judge -- not other perspectives' reasoning."""
262 249
263 def test_input_payload_has_no_cross_perspective_content(self): 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 root_cause_trace.judge_root_cause_via_work_graph( 255 root_cause_trace.judge_root_cause_via_work_graph(
270 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", 256 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context",
271 ) 257 )
@@ -286,7 +272,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase): @@ -286,7 +272,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase):
286 events = [] 272 events = []
287 events_lock = threading.Lock() 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 start = time.monotonic() 276 start = time.monotonic()
291 time.sleep(0.05) 277 time.sleep(0.05)
292 end = time.monotonic() 278 end = time.monotonic()
@@ -294,8 +280,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase): @@ -294,8 +280,7 @@ class ConcurrentPerspectiveExecutionTest(JudgeRootCauseViaWorkGraphTestBase):
294 events.append((start, end)) 280 events.append((start, end))
295 return {"category": "coder", "detail": "d"} 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 root_cause_trace.judge_root_cause_via_work_graph( 284 root_cause_trace.judge_root_cause_via_work_graph(
300 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", 285 self.conn, "task-1", "/tmp/proj-1", "bug desc", "context",
301 ) 286 )
src/test_scheduler.py
@@ -19,8 +19,8 @@ import unittest @@ -19,8 +19,8 @@ import unittest
19 from unittest.mock import patch 19 from unittest.mock import patch
20 20
21 from src import ( 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,9 +417,9 @@ def _fix_loop_trigger(org_node, prompt, target_folder_path, timeout=300):
417 raise AssertionError("unexpected call: " + org_node) 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 return {"category": "coder", "detail": "because reasons"} 423 return {"category": "coder", "detail": "because reasons"}
424 424
425 425
@@ -466,8 +466,7 @@ class SchedulerContinuesPastBlockedTaskTest(unittest.TestCase): @@ -466,8 +466,7 @@ class SchedulerContinuesPastBlockedTaskTest(unittest.TestCase):
466 with patch.object(scheduler.e2e_pipeline, "run_task_lifecycle", side_effect=fake_run_task_lifecycle), \ 466 with patch.object(scheduler.e2e_pipeline, "run_task_lifecycle", side_effect=fake_run_task_lifecycle), \
467 patch.object(scheduler.e2e_pipeline, "_read_artifact_content", return_value="benchmark content"), \ 467 patch.object(scheduler.e2e_pipeline, "_read_artifact_content", return_value="benchmark content"), \
468 patch("src.org_node_trigger.trigger_org_node", side_effect=_fix_loop_trigger), \ 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 results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=3) 470 results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=3)
472 471
473 by_goal = {r["goal_text"]: r for r in results} 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,7 +16,7 @@ from unittest.mock import patch
16 16
17 from src import ( 17 from src import (
18 e2e_pipeline, org_node_lifecycle, org_node_trigger, project_scaffold, schema, scheduler, 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,8 +185,7 @@ class ResumeFromCodingTest(TaskExecutionTestBase):
185 185
186 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 186 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
187 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 # validator's self-planned final_verdict node (Phase 2c) 189 # validator's self-planned final_verdict node (Phase 2c)
191 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] 190 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}]
192 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") 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,8 +217,7 @@ class ResumeFromPlanningTest(TaskExecutionTestBase):
218 217
219 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 218 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
220 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \ 221 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \
224 patch.object(e2e_pipeline, "plan_documents") as mock_plan: 222 patch.object(e2e_pipeline, "plan_documents") as mock_plan:
225 mock_execute.side_effect = [ 223 mock_execute.side_effect = [
@@ -263,8 +261,7 @@ class ResumeFromReviewingTest(TaskExecutionTestBase): @@ -263,8 +261,7 @@ class ResumeFromReviewingTest(TaskExecutionTestBase):
263 261
264 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=tracking_ready_trigger), \ 262 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=tracking_ready_trigger), \
265 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \ 265 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger, \
269 patch.object(e2e_pipeline, "plan_documents") as mock_plan: 266 patch.object(e2e_pipeline, "plan_documents") as mock_plan:
270 mock_execute.side_effect = [ 267 mock_execute.side_effect = [
@@ -313,8 +310,7 @@ class ResumeFromPlanningWithZeroArtifactsTest(TaskExecutionTestBase): @@ -313,8 +310,7 @@ class ResumeFromPlanningWithZeroArtifactsTest(TaskExecutionTestBase):
313 310
314 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 311 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
315 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"), \ 314 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"), \
319 patch.object(e2e_pipeline, "plan_documents", side_effect=fake_plan_documents) as mock_plan: 315 patch.object(e2e_pipeline, "plan_documents", side_effect=fake_plan_documents) as mock_plan:
320 mock_execute.side_effect = [ 316 mock_execute.side_effect = [
@@ -349,8 +345,7 @@ class ResumeFromValidatingTest(TaskExecutionTestBase): @@ -349,8 +345,7 @@ class ResumeFromValidatingTest(TaskExecutionTestBase):
349 345
350 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger) as mock_ready, \ 346 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger) as mock_ready, \
351 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node 349 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node
355 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") 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,8 +562,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
567 562
568 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 563 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
569 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node 566 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node
573 thread = task_execution.start_task_execution_in_background( 567 thread = task_execution.start_task_execution_in_background(
574 self.db_path, "task-1", self.repo.name, "a goal", 568 self.db_path, "task-1", self.repo.name, "a goal",
@@ -592,8 +586,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -592,8 +586,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
592 # a second thread that would run the same task twice. 586 # a second thread that would run the same task twice.
593 # 587 #
594 # Fully mocks the same real work the OTHER StartTaskExecutionIn 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 # this test blocks on -- an earlier version of this test only 591 # this test blocks on -- an earlier version of this test only
598 # patched the readiness trigger, so once unblocked the thread went on 592 # patched the readiness trigger, so once unblocked the thread went on
599 # to attempt REAL subprocess/file work, outlived this test's own 593 # to attempt REAL subprocess/file work, outlived this test's own
@@ -618,8 +612,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -618,8 +612,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
618 612
619 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=blocking_trigger), \ 613 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=blocking_trigger), \
620 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node 616 mock_execute.side_effect = [{"content": "VERDICT: PASS\nlooks good"}] # validator's self-planned final_verdict node
624 thread = task_execution.start_task_execution_in_background( 617 thread = task_execution.start_task_execution_in_background(
625 self.db_path, "task-1", self.repo.name, "a goal", 618 self.db_path, "task-1", self.repo.name, "a goal",
@@ -644,8 +637,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -644,8 +637,7 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
644 637
645 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 638 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
646 patch.object(work_graph_plan.subprocess, "run", side_effect=_fake_plan_subprocess_run_dispatch), \ 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 # Phase 2c: Validator's own verdict is now a self-planned node, 641 # Phase 2c: Validator's own verdict is now a self-planned node,
650 # not a flat org_node_trigger.trigger_org_node call -- the 642 # not a flat org_node_trigger.trigger_org_node call -- the
651 # failure this test cares about (a real exception surfacing as 643 # failure this test cares about (a real exception surfacing as
src/test_work_graph_fork.py
@@ -12,7 +12,7 @@ import tempfile @@ -12,7 +12,7 @@ import tempfile
12 import unittest 12 import unittest
13 from unittest.mock import patch 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 _BRD_PRD_SDD_BENCHMARK_MANIFEST = { 18 _BRD_PRD_SDD_BENCHMARK_MANIFEST = {
@@ -100,12 +100,11 @@ class ForkFromPrdAcceptanceCriterion1Test(ForkTestBase): @@ -100,12 +100,11 @@ class ForkFromPrdAcceptanceCriterion1Test(ForkTestBase):
100 100
101 execute_calls = [] 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 result = work_graph_fork.fork_work_graph_run( 108 result = work_graph_fork.fork_work_graph_run(
110 self.conn, node_exec_ids["prd"], {"content": "a completely different prd"}, "/tmp/proj-1", 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,12 +164,11 @@ class ForkFromMixedShapeGraphTest(ForkTestBase):
165 164
166 execute_calls = [] 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 result = work_graph_fork.fork_work_graph_run( 172 result = work_graph_fork.fork_work_graph_run(
175 self.conn, node_exec_ids["sdd"], {"content": "a completely different sdd"}, "/tmp/proj-1", 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,8 +197,7 @@ class OriginalRunUnaffectedTest(ForkTestBase):
199 "SELECT * FROM work_node_execution WHERE run_id = 'run-orig' ORDER BY node_id" 197 "SELECT * FROM work_node_execution WHERE run_id = 'run-orig' ORDER BY node_id"
200 ).fetchall() 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 work_graph_fork.fork_work_graph_run( 201 work_graph_fork.fork_work_graph_run(
205 self.conn, node_exec_ids["prd"], {"content": "different"}, "/tmp/proj-1", 202 self.conn, node_exec_ids["prd"], {"content": "different"}, "/tmp/proj-1",
206 ) 203 )
@@ -226,13 +223,11 @@ class MultipleForksFromSameNodeTest(ForkTestBase): @@ -226,13 +223,11 @@ class MultipleForksFromSameNodeTest(ForkTestBase):
226 "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents 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 result_a = work_graph_fork.fork_work_graph_run( 227 result_a = work_graph_fork.fork_work_graph_run(
232 self.conn, node_exec_ids["prd"], {"content": "prd variant A"}, "/tmp/proj-1", 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 result_b = work_graph_fork.fork_work_graph_run( 231 result_b = work_graph_fork.fork_work_graph_run(
237 self.conn, node_exec_ids["prd"], {"content": "prd variant B"}, "/tmp/proj-1", 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,8 +340,7 @@ class StartForkInBackgroundTest(unittest.TestCase):
345 os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home 340 os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home
346 341
347 def test_returns_run_id_immediately_and_completes_in_background(self): 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 run_id, thread = work_graph_fork.start_fork_in_background( 344 run_id, thread = work_graph_fork.start_fork_in_background(
351 self.db_path, "run-orig-prd", {"content": "new prd"}, "/tmp/proj-1", 345 self.db_path, "run-orig-prd", {"content": "new prd"}, "/tmp/proj-1",
352 ) 346 )
@@ -375,9 +369,8 @@ class ForkOfLeafNodeTest(ForkTestBase): @@ -375,9 +369,8 @@ class ForkOfLeafNodeTest(ForkTestBase):
375 node_exec_ids = self._seed_completed_run( 369 node_exec_ids = self._seed_completed_run(
376 "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents 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 result = work_graph_fork.fork_work_graph_run( 374 result = work_graph_fork.fork_work_graph_run(
382 self.conn, node_exec_ids["benchmark"], {"content": "different benchmark"}, "/tmp/proj-1", 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,8 +17,8 @@ import unittest
17 from unittest.mock import patch 17 from unittest.mock import patch
18 18
19 from src import ( 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,9 +268,8 @@ class RunGraphHappyPathTest(RunGraphTestBase):
268 calls.append((node["node_id"], dict(outputs))) 268 calls.append((node["node_id"], dict(outputs)))
269 return "prompt for " + node["node_id"] 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 outputs = wgo.run_graph( 273 outputs = wgo.run_graph(
275 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 274 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
276 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, 275 build_prompt, _IN_SCHEMA, _OUT_SCHEMA,
@@ -282,8 +281,7 @@ class RunGraphHappyPathTest(RunGraphTestBase): @@ -282,8 +281,7 @@ class RunGraphHappyPathTest(RunGraphTestBase):
282 281
283 def test_creates_work_node_execution_rows_and_placeholder_skill(self): 282 def test_creates_work_node_execution_rows_and_placeholder_skill(self):
284 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 285 wgo.run_graph(
288 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 286 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
289 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 287 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -308,9 +306,10 @@ class RunGraphHappyPathTest(RunGraphTestBase): @@ -308,9 +306,10 @@ class RunGraphHappyPathTest(RunGraphTestBase):
308 def build_prompt(node, outputs): 306 def build_prompt(node, outputs):
309 return "p-" + node["node_id"] 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 wgo.run_graph( 313 wgo.run_graph(
315 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 314 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
316 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, 315 build_prompt, _IN_SCHEMA, _OUT_SCHEMA,
@@ -318,9 +317,8 @@ class RunGraphHappyPathTest(RunGraphTestBase): @@ -318,9 +317,8 @@ class RunGraphHappyPathTest(RunGraphTestBase):
318 self.assertEqual(len(calls), 2) 317 self.assertEqual(len(calls), 2)
319 318
320 # simulate a restart: run_graph called again for the same run_id 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 outputs = wgo.run_graph( 322 outputs = wgo.run_graph(
325 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 323 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
326 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, 324 build_prompt, _IN_SCHEMA, _OUT_SCHEMA,
@@ -333,15 +331,14 @@ class RunGraphUsageLimitTest(RunGraphTestBase): @@ -333,15 +331,14 @@ class RunGraphUsageLimitTest(RunGraphTestBase):
333 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 331 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
334 attempts = {"n": 0} 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 attempts["n"] += 1 335 attempts["n"] += 1
338 if attempts["n"] == 1: 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 return {"content": "recovered"} 338 return {"content": "recovered"}
341 339
342 sleeps = [] 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 outputs = wgo.run_graph( 342 outputs = wgo.run_graph(
346 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 343 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
347 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 344 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -353,13 +350,18 @@ class RunGraphUsageLimitTest(RunGraphTestBase): @@ -353,13 +350,18 @@ class RunGraphUsageLimitTest(RunGraphTestBase):
353 350
354 class RunGraphClassifierBlockedTest(RunGraphTestBase): 351 class RunGraphClassifierBlockedTest(RunGraphTestBase):
355 def test_classifier_blocked_marks_node_blocked_and_propagates(self): 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 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 365 wgo.run_graph(
364 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 366 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
365 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 367 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -389,8 +391,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase): @@ -389,8 +391,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase):
389 def bad_build_prompt(node, outputs): 391 def bad_build_prompt(node, outputs):
390 raise KeyError("prd_content") 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 with self.assertRaises(KeyError): 395 with self.assertRaises(KeyError):
395 wgo.run_graph( 396 wgo.run_graph(
396 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 397 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -410,8 +411,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase): @@ -410,8 +411,7 @@ class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase):
410 def bad_build_prompt(node, outputs): 411 def bad_build_prompt(node, outputs):
411 raise RuntimeError("boom") 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 with self.assertRaises(RuntimeError): 415 with self.assertRaises(RuntimeError):
416 wgo.run_graph( 416 wgo.run_graph(
417 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 417 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -427,15 +427,16 @@ class VerifyFnTest(RunGraphTestBase): @@ -427,15 +427,16 @@ class VerifyFnTest(RunGraphTestBase):
427 427
428 def test_verify_fn_passing_immediately_does_not_regenerate(self): 428 def test_verify_fn_passing_immediately_does_not_regenerate(self):
429 manifest = {"nodes": [{"node_id": "prd", "depends_on": []}], "edges": []} 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 outputs = wgo.run_graph( 433 outputs = wgo.run_graph(
433 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 434 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
434 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 435 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
435 verify_fn=lambda node, output: (True, None), 436 verify_fn=lambda node, output: (True, None),
436 ) 437 )
437 self.assertEqual(outputs["prd"], {"content": "consistent prd"}) 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 run_status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] 440 run_status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0]
440 self.assertEqual(run_status, "running") # untouched -- verify_fn never failed 441 self.assertEqual(run_status, "running") # untouched -- verify_fn never failed
441 442
@@ -448,8 +449,9 @@ class VerifyFnTest(RunGraphTestBase): @@ -448,8 +449,9 @@ class VerifyFnTest(RunGraphTestBase):
448 checked.append(output["content"]) 449 checked.append(output["content"])
449 return output["content"] == "fixed prd", ["scenario A contradicts scenario B"] 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 outputs = wgo.run_graph( 455 outputs = wgo.run_graph(
454 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 456 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
455 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, verify_fn=verify_fn, 457 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, verify_fn=verify_fn,
@@ -465,8 +467,9 @@ class VerifyFnTest(RunGraphTestBase): @@ -465,8 +467,9 @@ class VerifyFnTest(RunGraphTestBase):
465 467
466 def test_verify_fn_failing_twice_raises_and_blocks_run(self): 468 def test_verify_fn_failing_twice_raises_and_blocks_run(self):
467 manifest = {"nodes": [{"node_id": "prd", "depends_on": []}], "edges": []} 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 with self.assertRaises(wgo.ConsistencyCheckFailedError): 473 with self.assertRaises(wgo.ConsistencyCheckFailedError):
471 wgo.run_graph( 474 wgo.run_graph(
472 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 475 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -480,9 +483,9 @@ class VerifyFnTest(RunGraphTestBase): @@ -480,9 +483,9 @@ class VerifyFnTest(RunGraphTestBase):
480 class SelfTargetForcesWorktreeTest(RunGraphTestBase): 483 class SelfTargetForcesWorktreeTest(RunGraphTestBase):
481 """U-035 (05 §5.8): self-target unconditionally forces needs_worktree=True, 484 """U-035 (05 §5.8): self-target unconditionally forces needs_worktree=True,
482 regardless of what the caller passed. run_work_node itself is mocked here 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 def test_self_target_forces_worktree_even_when_caller_passed_false(self): 490 def test_self_target_forces_worktree_even_when_caller_passed_false(self):
488 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 491 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
@@ -490,8 +493,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): @@ -490,8 +493,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase):
490 "UPDATE project SET target_folder_path = ? WHERE project_id = 'proj-1'", (paths.repo_root(),) 493 "UPDATE project SET target_folder_path = ? WHERE project_id = 'proj-1'", (paths.repo_root(),)
491 ) 494 )
492 self.conn.commit() 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 wgo.run_graph( 497 wgo.run_graph(
496 self.conn, "run-1", "planner", "planner-doc-writing", paths.repo_root(), manifest, 498 self.conn, "run-1", "planner", "planner-doc-writing", paths.repo_root(), manifest,
497 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False, 499 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False,
@@ -500,8 +502,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): @@ -500,8 +502,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase):
500 502
501 def test_non_self_target_respects_caller_supplied_false(self): 503 def test_non_self_target_respects_caller_supplied_false(self):
502 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 506 wgo.run_graph(
506 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 507 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
507 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False, 508 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=False,
@@ -510,8 +511,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): @@ -510,8 +511,7 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase):
510 511
511 def test_non_self_target_still_respects_caller_supplied_true(self): 512 def test_non_self_target_still_respects_caller_supplied_true(self):
512 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 515 wgo.run_graph(
516 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 516 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
517 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=True, 517 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, needs_worktree=True,
@@ -522,8 +522,9 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase): @@ -522,8 +522,9 @@ class SelfTargetForcesWorktreeTest(RunGraphTestBase):
522 class RunFnLogsExecutionEventsTest(RunGraphTestBase): 522 class RunFnLogsExecutionEventsTest(RunGraphTestBase):
523 """DU-01 (bug A1/A2 root cause): `_make_run_fn()`'s `run_fn` closure must 523 """DU-01 (bug A1/A2 root cause): `_make_run_fn()`'s `run_fn` closure must
524 actually call `execution_log.append_log()` around the real 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 def _node_exec_id(self, node_id="brd"): 529 def _node_exec_id(self, node_id="brd"):
529 return self.conn.execute( 530 return self.conn.execute(
@@ -536,8 +537,7 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): @@ -536,8 +537,7 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase):
536 537
537 def test_success_writes_node_start_then_node_completed(self): 538 def test_success_writes_node_start_then_node_completed(self):
538 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 541 wgo.run_graph(
542 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 542 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
543 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 543 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -556,12 +556,11 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): @@ -556,12 +556,11 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase):
556 556
557 def test_classifier_blocked_writes_node_failed_and_still_propagates(self): 557 def test_classifier_blocked_writes_node_failed_and_still_propagates(self):
558 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 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 wgo.run_graph( 564 wgo.run_graph(
566 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 565 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
567 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 566 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -575,14 +574,13 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase): @@ -575,14 +574,13 @@ class RunFnLogsExecutionEventsTest(RunGraphTestBase):
575 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} 574 manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []}
576 attempts = {"n": 0} 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 attempts["n"] += 1 578 attempts["n"] += 1
580 if attempts["n"] == 1: 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 return {"content": "recovered"} 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 outputs = wgo.run_graph( 584 outputs = wgo.run_graph(
587 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 585 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
588 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 586 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -610,16 +608,15 @@ class RunGraphConcurrencyTest(RunGraphTestBase): @@ -610,16 +608,15 @@ class RunGraphConcurrencyTest(RunGraphTestBase):
610 events = [] 608 events = []
611 events_lock = threading.Lock() 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 start = time.monotonic() 612 start = time.monotonic()
615 time.sleep(0.05) 613 time.sleep(0.05)
616 end = time.monotonic() 614 end = time.monotonic()
617 with events_lock: 615 with events_lock:
618 events.append((start, end)) 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 wgo.run_graph( 620 wgo.run_graph(
624 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 621 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
625 lambda node, outputs: "p-" + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, 622 lambda node, outputs: "p-" + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA,
@@ -642,7 +639,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase): @@ -642,7 +639,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase):
642 events = [] 639 events = []
643 events_lock = threading.Lock() 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 start = time.monotonic() 643 start = time.monotonic()
647 time.sleep(0.03) 644 time.sleep(0.03)
648 end = time.monotonic() 645 end = time.monotonic()
@@ -650,8 +647,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase): @@ -650,8 +647,7 @@ class RunGraphConcurrencyTest(RunGraphTestBase):
650 events.append((start, end)) 647 events.append((start, end))
651 return {"content": "x"} 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 wgo.run_graph( 651 wgo.run_graph(
656 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 652 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
657 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 653 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -681,8 +677,7 @@ class RunGraphLayerErrorTest(RunGraphTestBase): @@ -681,8 +677,7 @@ class RunGraphLayerErrorTest(RunGraphTestBase):
681 raise RuntimeError("boom on a") 677 raise RuntimeError("boom on a")
682 return {"content": "b done"} 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 with self.assertRaises(wgo.WorkGraphLayerError) as ctx: 681 with self.assertRaises(wgo.WorkGraphLayerError) as ctx:
687 wgo.run_graph( 682 wgo.run_graph(
688 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 683 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -707,21 +702,19 @@ class RunGraphConcurrentResumeTest(RunGraphTestBase): @@ -707,21 +702,19 @@ class RunGraphConcurrentResumeTest(RunGraphTestBase):
707 } 702 }
708 calls = [] 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 wgo.run_graph( 710 wgo.run_graph(
717 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 711 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
718 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 712 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
719 ) 713 )
720 self.assertEqual(len(calls), 2) 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 outputs = wgo.run_graph( 718 outputs = wgo.run_graph(
726 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 719 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
727 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 720 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -740,17 +733,9 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase): @@ -740,17 +733,9 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase):
740 "nodes": [{"node_id": "a", "depends_on": []}, {"node_id": "b", "depends_on": []}], 733 "nodes": [{"node_id": "a", "depends_on": []}, {"node_id": "b", "depends_on": []}],
741 "edges": [], 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 return {"content": "b done"} 739 return {"content": "b done"}
755 740
756 now_calls = {"n": 0} 741 now_calls = {"n": 0}
@@ -764,8 +749,7 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase): @@ -764,8 +749,7 @@ class RunGraphQuotaCoexistsWithNormalCompletionTest(RunGraphTestBase):
764 now_calls["n"] += 1 749 now_calls["n"] += 1
765 return 0.0 if now_calls["n"] == 1 else 999999.0 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 with self.assertRaises(wgo.WorkGraphLayerError) as ctx: 753 with self.assertRaises(wgo.WorkGraphLayerError) as ctx:
770 wgo.run_graph( 754 wgo.run_graph(
771 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 755 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -812,9 +796,9 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): @@ -812,9 +796,9 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase):
812 "edges": [], 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 time.sleep(0.005) 800 time.sleep(0.005)
817 - return {"content": name} 801 + return {"content": prompt}
818 802
819 for round_n in range(5): 803 for round_n in range(5):
820 db_path = os.path.join(self.db_tmp.name, "test_{}.db".format(round_n)) 804 db_path = os.path.join(self.db_tmp.name, "test_{}.db".format(round_n))
@@ -835,8 +819,7 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): @@ -835,8 +819,7 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase):
835 conn.commit() 819 conn.commit()
836 820
837 try: 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 outputs = wgo.run_graph( 823 outputs = wgo.run_graph(
841 conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 824 conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
842 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 825 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -932,24 +915,22 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase): @@ -932,24 +915,22 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase):
932 judgment = {"judge_prompt": "Review: {content}"} 915 judgment = {"judge_prompt": "Review: {content}"}
933 verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") 916 verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo")
934 with patch.object(wgo.paths, "is_self_target", return_value=False), \ 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 patch.object( 918 patch.object(
937 - work_graph_execute, "execute_work_graph", 919 + org_node_trigger, "trigger_org_node_structured",
938 return_value={"ok": True, "issues": []}, 920 return_value={"ok": True, "issues": []},
939 - ) as mock_execute: 921 + ) as mock_trigger:
940 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) 922 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"})
941 self.assertTrue(ok) 923 self.assertTrue(ok)
942 self.assertEqual(issues, []) 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 def test_string_ok_is_coerced(self): 928 def test_string_ok_is_coerced(self):
947 judgment = {"judge_prompt": "Review: {content}"} 929 judgment = {"judge_prompt": "Review: {content}"}
948 verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") 930 verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo")
949 with patch.object(wgo.paths, "is_self_target", return_value=False), \ 931 with patch.object(wgo.paths, "is_self_target", return_value=False), \
950 - patch.object(work_graph_execute, "write_workflow_script"), \  
951 patch.object( 932 patch.object(
952 - work_graph_execute, "execute_work_graph", 933 + org_node_trigger, "trigger_org_node_structured",
953 return_value={"ok": "false", "issues": ["bad"]}, 934 return_value={"ok": "false", "issues": ["bad"]},
954 ): 935 ):
955 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) 936 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"})
@@ -965,16 +946,14 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase): @@ -965,16 +946,14 @@ class LlmJudgmentVerifyFnTest(unittest.TestCase):
965 patch.object(wgo.work_node_run, "ensure_worktree_committed") as mock_ensure, \ 946 patch.object(wgo.work_node_run, "ensure_worktree_committed") as mock_ensure, \
966 patch.object(wgo.work_node_run, "merge_worktree") as mock_merge, \ 947 patch.object(wgo.work_node_run, "merge_worktree") as mock_merge, \
967 patch.object(wgo.work_node_run, "remove_worktree") as mock_remove, \ 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 patch.object( 949 patch.object(
970 - work_graph_execute, "execute_work_graph", 950 + org_node_trigger, "trigger_org_node_structured",
971 return_value={"ok": True, "issues": []}, 951 return_value={"ok": True, "issues": []},
972 - ) as mock_execute: 952 + ) as mock_trigger:
973 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) 953 ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"})
974 self.assertTrue(ok) 954 self.assertTrue(ok)
975 mock_create.assert_called_once_with("/repo", unittest.mock.ANY) 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 self.assertEqual(mock_ensure.call_count, 2) 957 self.assertEqual(mock_ensure.call_count, 2)
979 mock_merge.assert_called_once() 958 mock_merge.assert_called_once()
980 mock_remove.assert_called_once() 959 mock_remove.assert_called_once()
@@ -990,9 +969,8 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase): @@ -990,9 +969,8 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase):
990 "edges": [], 969 "edges": [],
991 } 970 }
992 registry = {"compiles": lambda content: (content == "real code", None if content == "real code" else "not code")} 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 outputs = wgo.run_graph( 974 outputs = wgo.run_graph(
997 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 975 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
998 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 976 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -1005,15 +983,14 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase): @@ -1005,15 +983,14 @@ class RunGraphMechanicalJudgmentTest(RunGraphTestBase):
1005 "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}}], 983 "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}}],
1006 "edges": [], 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 with self.assertRaises(ValueError): 987 with self.assertRaises(ValueError):
1011 wgo.run_graph( 988 wgo.run_graph(
1012 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 989 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1013 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 990 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
1014 mechanical_check_registry={"compiles": lambda c: (True, None)}, 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 class RunGraphLoopBackTest(RunGraphTestBase): 996 class RunGraphLoopBackTest(RunGraphTestBase):
@@ -1037,13 +1014,13 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1037,13 +1014,13 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1037 def build_prompt(node, outputs): 1014 def build_prompt(node, outputs):
1038 return "base prompt" 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 return {"content": contents.pop(0)} 1019 return {"content": contents.pop(0)}
1042 1020
1043 registry = {"compiles": lambda content: (content == "good", "not real code: " + content)} 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 outputs = wgo.run_graph( 1024 outputs = wgo.run_graph(
1048 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1025 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1049 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, 1026 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry,
@@ -1067,18 +1044,13 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1067,18 +1044,13 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1067 self.assertEqual(payloads[1]["attempt_number"], 2) 1044 self.assertEqual(payloads[1]["attempt_number"], 2)
1068 1045
1069 # the THIRD (successful) execution's prompt must carry both prior 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 def test_loop_back_to_earlier_node_reruns_target_and_downstream(self): 1055 def test_loop_back_to_earlier_node_reruns_target_and_downstream(self):
1084 # benchmark declares on_fail.action='loop_back' with an on_failure 1056 # benchmark declares on_fail.action='loop_back' with an on_failure
@@ -1110,8 +1082,8 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1110,8 +1082,8 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1110 # sdd v1, bad benchmark, sdd v2 (re-run via loop_back), good benchmark. 1082 # sdd v1, bad benchmark, sdd v2 (re-run via loop_back), good benchmark.
1111 call_order = [] 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 call_index = len(call_order) 1087 call_index = len(call_order)
1116 if call_index == 1: 1088 if call_index == 1:
1117 return {"content": sdd_outputs[0]} 1089 return {"content": sdd_outputs[0]}
@@ -1123,8 +1095,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1123,8 +1095,7 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1123 1095
1124 registry = {"compiles": lambda content: (content == "good benchmark", "rejected: " + content)} 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 outputs = wgo.run_graph( 1099 outputs = wgo.run_graph(
1129 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1100 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1130 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, 1101 build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry,
@@ -1150,8 +1121,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1150,8 +1121,7 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1150 "edges": [], 1121 "edges": [],
1151 } 1122 }
1152 registry = {"compiles": lambda content: (False, "always rejected")} 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 with self.assertRaises(wgo.ConsistencyCheckFailedError): 1125 with self.assertRaises(wgo.ConsistencyCheckFailedError):
1156 wgo.run_graph( 1126 wgo.run_graph(
1157 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1127 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -1170,14 +1140,15 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1170,14 +1140,15 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1170 "edges": [], 1140 "edges": [],
1171 } 1141 }
1172 registry = {"compiles": lambda content: (False, "bad")} 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 with self.assertRaises(wgo.ConsistencyCheckFailedError): 1146 with self.assertRaises(wgo.ConsistencyCheckFailedError):
1176 wgo.run_graph( 1147 wgo.run_graph(
1177 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1148 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1178 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, 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 def test_resume_after_restart_continues_from_the_real_attempt_count(self): 1153 def test_resume_after_restart_continues_from_the_real_attempt_count(self):
1183 # Simulates a PRIOR process that ran attempt 1 (rejected), recorded 1154 # Simulates a PRIOR process that ran attempt 1 (rejected), recorded
@@ -1221,8 +1192,9 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1221,8 +1192,9 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1221 # time succeeding -- must resume as attempt 2 (reading the true 1192 # time succeeding -- must resume as attempt 2 (reading the true
1222 # count from execution_event), not restart from attempt 1 or lose 1193 # count from execution_event), not restart from attempt 1 or lose
1223 # the earlier rejection's history. 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 outputs = wgo.run_graph( 1198 outputs = wgo.run_graph(
1227 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1199 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1228 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, 1200 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry,
@@ -1232,7 +1204,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1232,7 +1204,7 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1232 "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id" 1204 "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id"
1233 ).fetchall() 1205 ).fetchall()
1234 self.assertEqual([r[0] for r in rows], ["benchmark", "benchmark-attempt2"]) 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 def test_org_node_type_scoped_config_limit_used_when_no_node_level_override(self): 1209 def test_org_node_type_scoped_config_limit_used_when_no_node_level_override(self):
1238 from src import settings_config 1210 from src import settings_config
@@ -1246,8 +1218,9 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1246,8 +1218,9 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1246 "edges": [], 1218 "edges": [],
1247 } 1219 }
1248 registry = {"compiles": lambda content: (False, "bad")} 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 with self.assertRaises(wgo.ConsistencyCheckFailedError): 1224 with self.assertRaises(wgo.ConsistencyCheckFailedError):
1252 wgo.run_graph( 1225 wgo.run_graph(
1253 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1226 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
@@ -1258,7 +1231,7 @@ class RunGraphLoopBackTest(RunGraphTestBase): @@ -1258,7 +1231,7 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1258 # attempt_number=1 on the first failure is not > 1, so one retry 1231 # attempt_number=1 on the first failure is not > 1, so one retry
1259 # happens; attempt_number=2 on THAT failure is > 1 and exceeds. 1232 # happens; attempt_number=2 on THAT failure is > 1 and exceeds.
1260 # Two real executions total, not one. 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 class NodeLevelSubagentSkillTest(RunGraphTestBase): 1237 class NodeLevelSubagentSkillTest(RunGraphTestBase):
@@ -1274,8 +1247,7 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase): @@ -1274,8 +1247,7 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase):
1274 ], 1247 ],
1275 "edges": [], 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 wgo.run_graph( 1251 wgo.run_graph(
1280 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1252 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1281 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 1253 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
@@ -1301,28 +1273,20 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase): @@ -1301,28 +1273,20 @@ class NodeLevelSubagentSkillTest(RunGraphTestBase):
1301 }], 1273 }],
1302 "edges": [], 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 wgo.run_graph( 1285 wgo.run_graph(
1318 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1286 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1319 lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, 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 class DynamicLoopBackContentErrorTest(RunGraphTestBase): 1292 class DynamicLoopBackContentErrorTest(RunGraphTestBase):
@@ -1347,10 +1311,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase): @@ -1347,10 +1311,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase):
1347 node_call_count = [0] 1311 node_call_count = [0]
1348 lock = threading.Lock() 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 return {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "sdd is stale"} 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 with lock: 1319 with lock:
1355 node_call_count[0] += 1 1320 node_call_count[0] += 1
1356 idx = node_call_count[0] 1321 idx = node_call_count[0]
@@ -1361,12 +1326,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase): @@ -1361,12 +1326,11 @@ class DynamicLoopBackContentErrorTest(RunGraphTestBase):
1361 if idx == 3: 1326 if idx == 3:
1362 return {"content": sdd_contents[1]} 1327 return {"content": sdd_contents[1]}
1363 return {"content": "good final review"} 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 registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)} 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 outputs = wgo.run_graph( 1334 outputs = wgo.run_graph(
1371 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1335 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1372 lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, 1336 lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA,
@@ -1486,12 +1450,13 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase): @@ -1486,12 +1450,13 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase):
1486 node_call_count = [0] 1450 node_call_count = [0]
1487 lock = threading.Lock() 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 return {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "systemic issue"} 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 return {"new_content": "v2 content, fixed", "change_summary": "fixed the systemic issue"} 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 with lock: 1460 with lock:
1496 node_call_count[0] += 1 1461 node_call_count[0] += 1
1497 idx = node_call_count[0] 1462 idx = node_call_count[0]
@@ -1500,12 +1465,11 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase): @@ -1500,12 +1465,11 @@ class DynamicLoopBackSkillDefectTest(RunGraphTestBase):
1500 if idx == 2: 1465 if idx == 2:
1501 return {"content": "bad final review"} 1466 return {"content": "bad final review"}
1502 return {"content": "good final review"} 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 registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)} 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 outputs = wgo.run_graph( 1473 outputs = wgo.run_graph(
1510 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, 1474 self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
1511 lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA, 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,7 +12,7 @@ import subprocess
12 import unittest 12 import unittest
13 from unittest.mock import patch 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 _MANIFEST = { 17 _MANIFEST = {
18 "nodes": [ 18 "nodes": [
@@ -571,9 +571,10 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase): @@ -571,9 +571,10 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase):
571 # plan_work_graph() already inserted the work_graph_run row 571 # plan_work_graph() already inserted the work_graph_run row
572 # (status='planning') -- run_graph() doesn't require any particular 572 # (status='planning') -- run_graph() doesn't require any particular
573 # status on it, so nothing more to set up here. 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 outputs = work_graph_orchestrate.run_graph( 578 outputs = work_graph_orchestrate.run_graph(
578 self.conn, plan_result["run_id"], "coder", "coder-implementation", "/tmp/proj-1", 579 self.conn, plan_result["run_id"], "coder", "coder-implementation", "/tmp/proj-1",
579 plan_result["graph_manifest"], work_graph_plan.generic_node_prompt_builder, 580 plan_result["graph_manifest"], work_graph_plan.generic_node_prompt_builder,
@@ -581,7 +582,7 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase): @@ -581,7 +582,7 @@ class GenericPathEndToEndViaRunGraphTest(PlanWorkGraphTestBase):
581 ) 582 )
582 583
583 self.assertEqual(set(outputs.keys()), {"test-module-a", "test-module-b", "test-module-c"}) 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 if __name__ == "__main__": 588 if __name__ == "__main__":
src/work_graph_execute.py
1 """c_work_graph_execute (03-C §3.5): dynamic-workflow invocation, full implementation. 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 Writes an orchestration script into the target project's `.claude/workflows/ 17 Writes an orchestration script into the target project's `.claude/workflows/
4 <name>.js` and invokes it via `claude -p 'Run /<name> with args {...}'`, 18 <name>.js` and invokes it via `claude -p 'Run /<name> with args {...}'`,
5 reusing the exact code path the A-1 spike validated (spikes/a1-dynamic-workflows/, 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,7 +82,10 @@ def _quota_limit_type(text):
68 82
69 def write_workflow_script(target_folder_path, name, script_content): 83 def write_workflow_script(target_folder_path, name, script_content):
70 """Writes `<target>/.claude/workflows/<name>.js` -- versioned with the 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 workflows_dir = os.path.join(target_folder_path, ".claude", "workflows") 89 workflows_dir = os.path.join(target_folder_path, ".claude", "workflows")
73 os.makedirs(workflows_dir, exist_ok=True) 90 os.makedirs(workflows_dir, exist_ok=True)
74 path = os.path.join(workflows_dir, name + ".js") 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,6 +104,9 @@ def execute_work_graph(target_folder_path, name, args, output_schema, timeout=60
87 `structured_output` dict. Raises ClassifierBlockedError or 104 `structured_output` dict. Raises ClassifierBlockedError or
88 UsageLimitReachedError instead of the generic WorkGraphExecuteError when 105 UsageLimitReachedError instead of the generic WorkGraphExecuteError when
89 the response matches those specific signals. 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 prompt = "Run /{} with args {}".format(name, json.dumps(args)) 111 prompt = "Run /{} with args {}".format(name, json.dumps(args))
92 result = subprocess.run( 112 result = subprocess.run(
@@ -157,6 +177,13 @@ def execute_work_graph_with_usage_limit_retry( @@ -157,6 +177,13 @@ def execute_work_graph_with_usage_limit_retry(
157 non-opus model" means here. 177 non-opus model" means here.
158 178
159 `sleep`/`now` are injectable so tests don't block on real 5-30 minute waits. 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 start = now() 188 start = now()
162 backoff_index = 0 189 backoff_index = 0
src/work_graph_orchestrate.py
1 """Bridges `plan_work_graph()`'s `graph_manifest` to real per-node execution 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 REQ-06's breakpoint-recovery requirement (resume after a process restart 16 REQ-06's breakpoint-recovery requirement (resume after a process restart
17 without re-running already-`done` nodes) is only achievable with per-node, 17 without re-running already-`done` nodes) is only achievable with per-node,
18 Python-level persisted state (`work_node_run.py`'s whole design), which is 18 Python-level persisted state (`work_node_run.py`'s whole design), which is
19 fundamentally incompatible with wrapping the WHOLE graph in one opaque 19 fundamentally incompatible with wrapping the WHOLE graph in one opaque
20 `claude` subprocess call Python has no visibility into mid-flight. 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 **`verify_fn` (2026-08-02, PH4-closure audit before starting PH5)**: closes 47 **`verify_fn` (2026-08-02, PH4-closure audit before starting PH5)**: closes
28 REQ-02's boundary case (PRD internal-contradiction check must intercept 48 REQ-02's boundary case (PRD internal-contradiction check must intercept
@@ -43,17 +63,17 @@ doesn&#39;t describe either. @@ -43,17 +63,17 @@ doesn&#39;t describe either.
43 **DU-01 (2026-08-04, bug A1/A2 root-cause fix)**: `_make_run_fn()`'s `run_fn` 63 **DU-01 (2026-08-04, bug A1/A2 root-cause fix)**: `_make_run_fn()`'s `run_fn`
44 closure is the natural place to call `execution_log.append_log()` -- it's 64 closure is the natural place to call `execution_log.append_log()` -- it's
45 the one spot that both has `node_exec_id` and wraps the actual blocking 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 `execution_log_file` rows existed with zero bytes of real content forever 70 `execution_log_file` rows existed with zero bytes of real content forever
51 -- the console's "暂无日志内容" was a correct rendering of genuinely empty 71 -- the console's "暂无日志内容" was a correct rendering of genuinely empty
52 data, and downloading it crashed `backend_server._send_file_download()` 72 data, and downloading it crashed `backend_server._send_file_download()`
53 with an uncaught `FileNotFoundError` (also fixed here, see that function). 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 not a real character-by-character stream: `node_start` (before the call), 77 not a real character-by-character stream: `node_start` (before the call),
58 `node_completed` (after a successful return), `node_failed` (any other 78 `node_completed` (after a successful return), `node_failed` (any other
59 exception, re-raised unchanged after logging), `quota_limit` (the 79 exception, re-raised unchanged after logging), `quota_limit` (the
@@ -72,7 +92,10 @@ import time @@ -72,7 +92,10 @@ import time
72 import uuid 92 import uuid
73 from collections import Counter 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 def _log_event(conn, node_exec_id, event_type, **payload): 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,28 +383,14 @@ def _ensure_node_row(conn, run_id, node_id, subagent_type, skill_ref, input_sche
360 return node_exec_id 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 def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): 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 def run_fn(cwd, model=None): 387 def run_fn(cwd, model=None):
379 _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model) 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 try: 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 _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type) 394 _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type)
386 work_node_run.raise_usage_limit_signal(e.quota_limit_type) 395 work_node_run.raise_usage_limit_signal(e.quota_limit_type)
387 except Exception as e: 396 except Exception as e:
@@ -445,17 +454,7 @@ def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180) @@ -445,17 +454,7 @@ def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180)
445 cwd = worktree_path or target_folder_path 454 cwd = worktree_path or target_folder_path
446 try: 455 try:
447 prompt = judgment["judge_prompt"].format(content=output["content"]) 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 if worktree_path: 458 if worktree_path:
460 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) 459 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id)
461 work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") 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,17 +609,9 @@ def _run_reflection_perspective(framing_name, prompt, target_folder_path, timeou
610 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None 609 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None
611 cwd = worktree_path or target_folder_path 610 cwd = worktree_path or target_folder_path
612 try: 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 if worktree_path: 615 if worktree_path:
625 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) 616 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id)
626 work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") 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,14 +783,9 @@ def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prom
792 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None 783 worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None
793 cwd = worktree_path or target_folder_path 784 cwd = worktree_path or target_folder_path
794 try: 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 if worktree_path: 789 if worktree_path:
804 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) 790 work_node_run.ensure_worktree_committed(worktree_path, node_exec_id)
805 work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") 791 work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift")
@@ -964,8 +950,8 @@ def run_graph( @@ -964,8 +950,8 @@ def run_graph(
964 mechanical_check_registry=None, 950 mechanical_check_registry=None,
965 ): 951 ):
966 """Runs every node in `graph_manifest` through `run_work_node()`, each 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 layers (`topological_layers()`) and run layer-by-layer with a barrier 955 layers (`topological_layers()`) and run layer-by-layer with a barrier
970 in between; within a layer -- nodes that are mutually independent by 956 in between; within a layer -- nodes that are mutually independent by
971 construction -- a layer with 2+ nodes runs them concurrently via a 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,22 +339,44 @@ class PlanningFailedError(Exception):
339 """PLANNING_FAILED -- 03-C §3.4.""" 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 """--json-schema doesn't constrain output when --agent is set (see module 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 instead: try the whole text as JSON, then each ```json fenced block; 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 candidates = [text] + _JSON_FENCE_RE.findall(text) 354 candidates = [text] + _JSON_FENCE_RE.findall(text)
350 for candidate in candidates: 355 for candidate in candidates:
351 try: 356 try:
352 parsed = json.loads(candidate) 357 parsed = json.loads(candidate)
353 except (json.JSONDecodeError, TypeError): 358 except (json.JSONDecodeError, TypeError):
354 continue 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 if isinstance(parsed, dict): 379 if isinstance(parsed, dict):
356 - if "nodes" in parsed and "edges" in parsed:  
357 - return parsed  
358 inner = parsed.get("graph_manifest") 380 inner = parsed.get("graph_manifest")
359 if isinstance(inner, dict) and "nodes" in inner and "edges" in inner: 381 if isinstance(inner, dict) and "nodes" in inner and "edges" in inner:
360 return inner 382 return inner