Commit a991468d615866cbb4e38e39a13e6a4b3d6a8cc1
1 parent
a01d1c0e
Work Graph node execution deblackbox: drop native Dynamic Workflow for single-node calls
Implements docs/done/work-graph-node-execution-deblackbox-requirements.md end to end. The three documented call sites (e2e_pipeline.py:: _check_prd_consistency, work_graph_orchestrate.py::_llm_judgment_verify_fn, work_graph_orchestrate.py::_make_run_fn) all used to render a one-off Dynamic Workflow .js script and run it via `claude -p 'Run /<name>...'` purely to get "agent identity + schema-shaped output" -- a workaround for --json-schema silently conflicting with --agent (05 §5.4.1.1). That workaround was never actually necessary: work_graph_plan.py:: plan_work_graph() solved the identical problem from day one with a flat `claude --agent ... -p ...` call + ```json-fence extraction from free text, already validated in production. New org_node_trigger.trigger_org_node_structured() unifies all three call sites onto that same flat-call approach. extract_json_payload() generalizes work_graph_plan.py's fence-extraction algorithm; _extract_graph_manifest is now a thin wrapper over it. Two more call sites not in the original design doc (written before they existed) were found and migrated too: _run_reflection_perspective and _fix_skill_for_node (Phase 2a's Reflection-review/Fix-Skill). work_graph_fork.py needed no changes -- it only reaches _make_run_fn indirectly via run_graph(), so it benefits automatically. FR-4's real-agent verification (RUN_REAL_AGENT_E2E=1, mandatory, not optional -- src/test_org_node_trigger_e2e.py) confirmed persona loading, --model override, and JSON-fence extraction all work against a real model. write_workflow_script/execute_work_graph/execute_work_graph_with_usage_ limit_retry are confirmed to have no remaining production callers; docstrings mark them historical, the file is kept (not deleted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
14 changed files
with
677 additions
and
426 deletions
src/e2e_pipeline.py
| @@ -53,7 +53,7 @@ import uuid | @@ -53,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's job, not this one. | @@ -8,18 +8,155 @@ handling is U-010'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't describe either. | @@ -43,17 +63,17 @@ doesn'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 |